commit 20acd6d77ad36472fbce9ad48f7171d98d24f84f Author: rkmpa Date: Sat Mar 14 22:45:37 2026 +0100 Init with working version Basic demo for Tally X AutoIxpert integration to showcase setup in Go due to impossibility of open-sourcing n8n setup. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2a190f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +main +autoixpert-sample +Makefile +config.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e7cbb2 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +## Tally / AutoIxpert integration + +### Überblick + +Diese Codebasis ist eine Demo, die zeigt wie ein Tallyformular mit AutoIxpert integriert wird, +um einen SV Büro die Möglichkeit zu schaffen, Kundenanfragen direkt in Qapter aufzunehmen. + +Ursprünglich in n8n geschrieben und gestaltet, wurde es hier in Go neugeschrieben, um die Nutzung +einiger wichtiger Endpunkte zu demonstrieren. Darunter zählen: + +- die Erstellung eines Gutachtens mit Labels +- der Upload von Fotos via 3 Endpunkten (Metadatenerstellung, Uploadlinkabfrage, Upload) + +Der Code besteht aus einem stdlib Go Webserver, der einen einzigen Endpunkt hat. Dieser empfängt +im Stil eines Webhooks eine vordefinierte Anfrage von Tally, verarbeitet diese und erstellt Gutachten und +lädt Bilder hoch. + +Der Code ist funktional, aber nicht produktionsreif. Folgende Stellen müssten für eine Produktionsversion noch verbessert werden: + +- striktere Validierung der eingehenden Daten webserverseitig (Tally erlaubt allerdings relativ strikte Validierung auf Formularseite) +- verbesserte Fehlerhandhabung (einbinden der Errorlogs in Systemlog und Anbindung an Alteringlösung); Reaktion des Systems auf verschiedene API Fehler +- Individualisierungslösung für Gutachtenlabels (Erstellung des Labels falls nichtexistent, in diesem Fall hardgecoded für speziellen Kunden) + + +### Setup + +#### Abhängigkeiten + +- Go v1.25 + +Installation von Go mit gänginge Paketmanagern: + +Ubuntu: + +```bash +snap install go --classic +``` + +MacOS + +```bash +brew install go +``` + +#### Konfiguration + +Die Konfiguration kann in der config.example.json angepasst werden. Es wird mindestens ein API Key von Qapter benötigt und +in diesem Fall ein Secret von Tally. Beide können in den jeweiligen Systemen abgerufen werden und müssen in die Konfiguration +kopiert werden. + +Die Anwendung erwartet die Datei unter dem Pfad `config.json` im Rootverzeichnis. Dies wird durch folgenden Befehl erreicht: + +````bash +cp config.example.json config.json +```` + +#### Run + +Die Anwendung kann mit folgendem Befehl gestartet werden: + +````bash +go run . +```` \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..89265ed --- /dev/null +++ b/api.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "io" + "net/http" + "strconv" + "strings" + + "git.kornbrand.biz/plankalkul/autoixpert-sample/pkg/models" +) + +type apiError struct { + StatusCode int `json:"status_code"` + Endpoint string `json:"endpoint"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` + StackTrace string `json:"stack_trace"` +} + +// could be more elaborate depending on the users needs, for simplicity just the API error messageu +// depending on usecase, might be useful to include all fields of this in the slog as parsebable output +func (e *apiError) Error() string { + if e.ErrorMessage == "" { + return fmt.Sprintf("api request: %s failed with status %d", e.Endpoint, e.StatusCode) + } + return e.ErrorMessage +} + +func decodeAPIError(resp *http.Response) error { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("api request failed with status %d (failed to read error response: %w)", resp.StatusCode, readErr) + } + + var apiErr apiError + if err := json.Unmarshal(body, &apiErr); err != nil { + return fmt.Errorf("api request failed with status %d (failed to decode error response: %w)", resp.StatusCode, err) + } + + return &apiErr +} + +func createReport(report models.CrashReport) (string, error) { + + var body = report.ToClaimRequest() + data, err := json.Marshal(body) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", cfg.APIBase+"/reports", bytes.NewBuffer(data)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := DoWithRateLimitReset(context.Background(), req) + + if err != nil { + return "", err + } + + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", decodeAPIError(resp) + } + + var claimResp models.ClaimResponse + if err := json.NewDecoder(resp.Body).Decode(&claimResp); err != nil { + return "", err + } + + return claimResp.Document.ID, nil +} + +func uploadImages(imageURLs []string, id string, eventID string) error { + images, err := collectImages(imageURLs) + if err != nil { + return err + } + + metaResp, err := requestPhotoMetadata(id, eventID, images) + if err != nil { + return err + } + + uploadResp, err := requestUploadURLs(id, eventID, metaResp) + if err != nil { + return err + } + + return uploadPhotoBinaries(images, metaResp, uploadResp) +} + +func collectImages(imageURLs []string) ([]imageWithMeta, error) { + images := make([]imageWithMeta, 0, len(imageURLs)) + + for idx, url := range imageURLs { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + + mime := http.DetectContentType(data) + size := len(data) + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + images = append(images, imageWithMeta{ + Data: data, + Meta: models.ImageMetaRequest{ + Title: strconv.Itoa(idx), + Description: "", + OriginalName: strconv.Itoa(idx), + Mimetype: mime, + Size: int64(size), + Width: cfg.Width, + Height: cfg.Height, + IncludedInReport: true, + }, + }) + } + + return images, nil +} + +func requestPhotoMetadata(reportID string, eventID string, images []imageWithMeta) (models.ImageMetaResponse, error) { + metas := make([]models.ImageMetaRequest, 0, len(images)) + for _, img := range images { + metas = append(metas, img.Meta) + } + + body, err := json.Marshal(models.ImageMetaRequestWrapper{Photos: metas}) + if err != nil { + return models.ImageMetaResponse{}, err + } + + req, err := http.NewRequest("POST", cfg.APIBase+"/reports/"+reportID+"/photos/batch", bytes.NewBuffer(body)) + if err != nil { + return models.ImageMetaResponse{}, err + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := DoWithRateLimitReset(context.Background(), req) + if err != nil { + return models.ImageMetaResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return models.ImageMetaResponse{}, decodeAPIError(resp) + } + + var metaResp models.ImageMetaResponse + if err := json.NewDecoder(resp.Body).Decode(&metaResp); err != nil { + return models.ImageMetaResponse{}, err + } + + return metaResp, nil +} + +func requestUploadURLs(reportID string, eventID string, metaResp models.ImageMetaResponse) (models.UploadURLResponse, error) { + ids := make([]string, 0, len(metaResp.Photos)) + for _, photo := range metaResp.Photos { + ids = append(ids, photo.ID) + } + + req, err := http.NewRequest("GET", cfg.APIBase+"/reports/"+reportID+"/photos/batch/upload?photo_ids="+strings.Join(ids, ","), nil) + if err != nil { + return models.UploadURLResponse{}, err + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := DoWithRateLimitReset(context.Background(), req) + if err != nil { + return models.UploadURLResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return models.UploadURLResponse{}, decodeAPIError(resp) + } + + var uploadResp models.UploadURLResponse + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return models.UploadURLResponse{}, err + } + + return uploadResp, nil +} + +func uploadPhotoBinaries(images []imageWithMeta, metaResp models.ImageMetaResponse, uploadResp models.UploadURLResponse) error { + imageByTitle := make(map[string]imageWithMeta, len(images)) + for _, img := range images { + imageByTitle[img.Meta.Title] = img + } + + titleByPhotoID := make(map[string]string, len(metaResp.Photos)) + for _, metaImg := range metaResp.Photos { + titleByPhotoID[metaImg.ID] = metaImg.Title + } + + for _, upload := range uploadResp.UploadURLs { + title, ok := titleByPhotoID[upload.PhotoID] + if !ok { + continue + } + + img, ok := imageByTitle[title] + if !ok { + continue + } + + putReq, err := http.NewRequest("PUT", upload.UploadURL, bytes.NewReader(img.Data)) + if err != nil { + return err + } + putReq.Header.Set("Content-Type", img.Meta.Mimetype) + + resp, err := Do(context.Background(), putReq) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + } + + } + + return nil +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..174b249 --- /dev/null +++ b/config.example.json @@ -0,0 +1,6 @@ +{ + "api_key": "autoIxpert-key", + "port": ":9080", + "api_base": "https://app.autoixpert.de/externalApi/v1", + "secret_key": "autoIxpert-secret" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a56d92b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.kornbrand.biz/plankalkul/autoixpert-sample + +go 1.22.0 diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..d3f5eae --- /dev/null +++ b/helper.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const maxRetries = 3 + +var client = &http.Client{ + Timeout: 30 * time.Second, +} + +func Do(ctx context.Context, req *http.Request) (*http.Response, error) { + return client.Do(req) +} + +func DoWithRateLimitReset(ctx context.Context, req *http.Request) (*http.Response, error) { + var getBody func() (io.ReadCloser, error) + if req.Body != nil { + b, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + + getBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(b)), nil + } + req.GetBody = getBody + req.Body, _ = getBody() + } + + const safety = 250 * time.Millisecond // small buffer to avoid edge timing + + for attempt := 0; attempt <= maxRetries; attempt++ { + r := req.Clone(ctx) + if getBody != nil { + r.Body, _ = getBody() + } + + resp, err := client.Do(r) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusTooManyRequests { + return resp, nil + } + + resetHeader := resp.Header.Get("X-RateLimit-Reset") + _ = resp.Body.Close() + + if attempt == maxRetries { + return nil, fmt.Errorf("rate limited (429) after %d retries; last reset header=%q", maxRetries, resetHeader) + } + + wait, err := waitUntilUnixReset(resetHeader, safety) + if err != nil { + // If the header is missing/invalid, fall back to a short backoff + wait = 2 * time.Second + } + + select { + case <-time.After(wait): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, fmt.Errorf("unreachable") +} + +func waitUntilUnixReset(h string, safety time.Duration) (time.Duration, error) { + secs, err := strconv.ParseInt(h, 10, 64) + if err != nil { + return 0, err + } + + resetAt := time.Unix(secs, 0) + wait := time.Until(resetAt) + safety + if wait < 0 { + wait = 0 + } + return wait, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..749a697 --- /dev/null +++ b/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + _ "image/jpeg" + _ "image/png" + "log/slog" + "net/http" + "os" + + "git.kornbrand.biz/plankalkul/autoixpert-sample/pkg/models" +) + +type Config struct { + Port string `json:"port"` + APIBase string `json:"api_base"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` +} + +var cfg Config + +type imageWithMeta struct { + Data []byte + Meta models.ImageMetaRequest +} + +func main() { + + slog.Info("Starting AutoIxpert Sample App") + + config, err := os.Open("config.json") + if err != nil { + slog.Error("Failed to open config file", "error", err) + os.Exit(1) + } + + defer config.Close() + + if err := json.NewDecoder(config).Decode(&cfg); err != nil { + slog.Error("Failed to decode config file", "error", err) + os.Exit(1) + } + + http.HandleFunc("POST /", handle) + + slog.Info("Server is listening on port " + cfg.Port) + if err := http.ListenAndServe(cfg.Port, nil); err != nil { + slog.Error("Listen and serve failed", "error", err) + os.Exit(1) + } +} + +func handle(w http.ResponseWriter, r *http.Request) { + + slog.Info("Received request on /") + + defer r.Body.Close() + + header := r.Header.Get("X-API-Key") + if header != cfg.SecretKey { + slog.Warn("Unauthorized request received") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req models.Webhook + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + slog.Error("Failed to decode request body", "error", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + report := req.ToCrashReport() + + id, err := createReport(report) + if err != nil { + slog.Error("Failed to create report", "eventID", report.EventID, "error", err) + http.Error(w, "failed to create report", http.StatusInternalServerError) + return + } + + err = uploadImages(report.Images, id, report.EventID) + if err != nil { + slog.Error("Failed to upload images", "eventID", report.EventID, "error", err) + http.Error(w, "failed to upload images", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/models/ixpert.go b/pkg/models/ixpert.go new file mode 100644 index 0000000..efe8198 --- /dev/null +++ b/pkg/models/ixpert.go @@ -0,0 +1,75 @@ +package models + +import "time" + +type ClaimRequest struct { + Type string `json:"type"` + Labels []string `json:"labels"` + Accident Accident `json:"accident"` + Car Car `json:"car"` + Claimant Claimant `json:"claimant"` +} + +type Accident struct { + Location string `json:"location"` + Circumstances string `json:"circumstances"` + Time time.Time `json:"time"` +} + +type Car struct { + LicensePlate string `json:"license_plate"` +} + +type Claimant struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + City string `json:"city"` + Zip string `json:"zip"` + StreetAndHousenumberOrLockbox string `json:"street_and_housenumber_or_lockbox"` + Phone string `json:"phone"` + Email string `json:"email"` +} + +type ClaimResponse struct { + Document Document `json:"report"` +} + +type Document struct { + ID string `json:"id"` +} + +type ImageMetaRequestWrapper struct { + Photos []ImageMetaRequest `json:"photos"` +} + +type ImageMetaRequest struct { + Title string `json:"title"` + Description string `json:"description"` + OriginalName string `json:"original_name"` + Mimetype string `json:"mimetype"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + IncludedInReport bool `json:"included_in_report"` +} + +type ImageMetaResponse struct { + Photos []PhotoID `json:"photos"` + Errors []any `json:"errors"` // empty array in example, keep flexible +} + +type PhotoID struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type UploadURLResponse struct { + UploadURLs []UploadURL `json:"upload_urls"` + Errors []any `json:"errors"` +} + +type UploadURL struct { + PhotoID string `json:"photo_id"` + UploadURL string `json:"upload_url"` + ExpiresIn int `json:"expires_in"` +} diff --git a/pkg/models/report.go b/pkg/models/report.go new file mode 100644 index 0000000..dc59d85 --- /dev/null +++ b/pkg/models/report.go @@ -0,0 +1,52 @@ +package models + +import ( + "strconv" + "time" +) + +type CrashReport struct { + EventID string + SubmissionID string + + FirstName string + LastName string + Street string + Postcode int + City string + Phone string + Email string + + LicensePlate string + + CrashDate time.Time + CrashLocation string + CrashDescription string + CrashOpponent string + + Images []string +} + +func (r CrashReport) ToClaimRequest() ClaimRequest { + return ClaimRequest{ + Type: "liability", + Labels: []string{"vTyXeQFryVNA"}, // add to be reviewed label for specific customer + Accident: Accident{ + Location: r.CrashLocation, + Circumstances: r.CrashDescription, + Time: r.CrashDate, + }, + Car: Car{ + LicensePlate: r.LicensePlate, + }, + Claimant: Claimant{ + FirstName: r.FirstName, + LastName: r.LastName, + City: r.City, + Zip: strconv.Itoa(r.Postcode), + StreetAndHousenumberOrLockbox: r.Street, + Phone: r.Phone, + Email: r.Email, + }, + } +} diff --git a/pkg/models/tally.go b/pkg/models/tally.go new file mode 100644 index 0000000..14a6149 --- /dev/null +++ b/pkg/models/tally.go @@ -0,0 +1,156 @@ +package models + +import ( + "encoding/json" + "time" +) + +type Webhook struct { + EventID string `json:"eventId"` + CreatedAt time.Time `json:"createdAt"` + Data Data `json:"data"` +} + +type Data struct { + ResponseID string `json:"responseId"` + SubmissionID string `json:"submissionId"` + RespondentID string `json:"respondentId"` + FormID string `json:"formId"` + FormName string `json:"formName"` + CreatedAt time.Time `json:"createdAt"` + Fields []Field `json:"fields"` +} + +type Field struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Value json.RawMessage `json:"value"` + Options []Option `json:"options,omitempty"` +} + +type Option struct { + ID string `json:"id"` + Text string `json:"text"` +} + +type File struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + MimeType string `json:"mimeType"` + Size int64 `json:"size"` +} + +func (d Data) FieldMap() map[string]Field { + m := make(map[string]Field, len(d.Fields)) + for _, f := range d.Fields { + m[f.Key] = f + } + return m +} + +func GetString(m map[string]Field, key string) (string, bool) { + f, ok := m[key] + if !ok { + return "", false + } + var s string + if err := json.Unmarshal(f.Value, &s); err != nil { + return "", false + } + return s, true +} + +func GetInt(m map[string]Field, key string) (int, bool) { + f, ok := m[key] + if !ok { + return 0, false + } + var n int + if err := json.Unmarshal(f.Value, &n); err != nil { + // some systems send numbers as float64 in JSON; handle that too + var nf float64 + if err2 := json.Unmarshal(f.Value, &nf); err2 != nil { + return 0, false + } + return int(nf), true + } + return n, true +} + +func GetStringSlice(m map[string]Field, key string) ([]string, bool) { + f, ok := m[key] + if !ok { + return nil, false + } + var s []string + if err := json.Unmarshal(f.Value, &s); err != nil { + return nil, false + } + return s, true +} + +func GetFiles(m map[string]Field, key string) ([]File, bool) { + f, ok := m[key] + if !ok { + return nil, false + } + var files []File + if err := json.Unmarshal(f.Value, &files); err != nil { + return nil, false + } + return files, true +} + +func (w Webhook) ToCrashReport() CrashReport { + fm := w.Data.FieldMap() + + eventID := w.EventID + submissionID := w.Data.SubmissionID + + firstName, _ := GetString(fm, "question_LdEGVy") + lastName, _ := GetString(fm, "question_pL0A51") + street, _ := GetString(fm, "question_1rbKOg") + postcode, _ := GetInt(fm, "question_MArOPl") + city, _ := GetString(fm, "question_J2VRGr") + phone, _ := GetString(fm, "question_g5QAgP") + email, _ := GetString(fm, "question_yl0yg8") + + licensePlate, _ := GetString(fm, "question_XeRG9P") + + licenseFiles, _ := GetFiles(fm, "question_8dJk2l") + crashDate, _ := GetString(fm, "question_0EbMG9") + crashLocation, _ := GetString(fm, "question_zK0zgk") + crashDescription, _ := GetString(fm, "question_5dlkrN") + crashImages, _ := GetFiles(fm, "question_dYZAgr") + + images := make([]string, 0) + for _, f := range licenseFiles { + images = append(images, f.URL) + } + for _, f := range crashImages { + images = append(images, f.URL) + } + + crashDateParsed, _ := time.Parse("2006-01-02", crashDate) + + return CrashReport{ + EventID: eventID, + SubmissionID: submissionID, + + FirstName: firstName, + LastName: lastName, + Street: street, + Postcode: postcode, + City: city, + Phone: phone, + Email: email, + + LicensePlate: licensePlate, + CrashLocation: crashLocation, + CrashDescription: crashDescription, + Images: images, + CrashDate: crashDateParsed, + } +}