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