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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
main
|
||||||
|
autoixpert-sample
|
||||||
|
Makefile
|
||||||
|
config.json
|
||||||
63
README.md
Normal file
63
README.md
Normal 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
249
api.go
Normal 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
6
config.example.json
Normal 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
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.kornbrand.biz/plankalkul/autoixpert-sample
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
91
helper.go
Normal file
91
helper.go
Normal 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
92
main.go
Normal 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
75
pkg/models/ixpert.go
Normal 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
52
pkg/models/report.go
Normal 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
156
pkg/models/tally.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user