From 20acd6d77ad36472fbce9ad48f7171d98d24f84f Mon Sep 17 00:00:00 2001 From: rkmpa Date: Sat, 14 Mar 2026 22:45:37 +0100 Subject: [PATCH] Init with working version Basic demo for Tally X AutoIxpert integration to showcase setup in Go due to impossibility of open-sourcing n8n setup. --- .gitignore | 4 + README.md | 63 +++++++++++ api.go | 249 +++++++++++++++++++++++++++++++++++++++++++ config.example.json | 6 ++ go.mod | 3 + helper.go | 91 ++++++++++++++++ main.go | 92 ++++++++++++++++ pkg/models/ixpert.go | 75 +++++++++++++ pkg/models/report.go | 52 +++++++++ pkg/models/tally.go | 156 +++++++++++++++++++++++++++ 10 files changed, 791 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api.go create mode 100644 config.example.json create mode 100644 go.mod create mode 100644 helper.go create mode 100644 main.go create mode 100644 pkg/models/ixpert.go create mode 100644 pkg/models/report.go create mode 100644 pkg/models/tally.go 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, + } +}