Init with working version

Basic demo for Tally X AutoIxpert integration to showcase setup in
Go due to impossibility of open-sourcing n8n setup.
This commit is contained in:
rkmpa
2026-03-14 22:45:37 +01:00
commit 20acd6d77a
10 changed files with 791 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
main
autoixpert-sample
Makefile
config.json

63
README.md Normal file
View File

@@ -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 .
````

249
api.go Normal file
View File

@@ -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
}

6
config.example.json Normal file
View File

@@ -0,0 +1,6 @@
{
"api_key": "autoIxpert-key",
"port": ":9080",
"api_base": "https://app.autoixpert.de/externalApi/v1",
"secret_key": "autoIxpert-secret"
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.kornbrand.biz/plankalkul/autoixpert-sample
go 1.22.0

91
helper.go Normal file
View File

@@ -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
}

92
main.go Normal file
View File

@@ -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)
}

75
pkg/models/ixpert.go Normal file
View File

@@ -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"`
}

52
pkg/models/report.go Normal file
View File

@@ -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,
},
}
}

156
pkg/models/tally.go Normal file
View File

@@ -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,
}
}