package main import ( "crypto/sha256" "encoding/json" "fmt" "net/http" "strings" "inou/lib" ) type readingsRequest struct { DossierID string `json:"dossier_id"` Category string `json:"category"` Readings []struct { Source string `json:"source"` Metric string `json:"metric"` Value float64 `json:"value"` Unit string `json:"unit"` Timestamp int64 `json:"timestamp"` } `json:"readings"` } // POST /api/v2/readings func v2Readings(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { v1Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } authID, ok := v1AuthRequired(w, r) if !ok { return } var req readingsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { v1Error(w, "Invalid request body", http.StatusBadRequest) return } if req.DossierID == "" { v1Error(w, "dossier_id required", http.StatusBadRequest) return } if req.Category == "" { v1Error(w, "category required", http.StatusBadRequest) return } catInt, catOK := lib.CategoryFromString[req.Category] if !catOK { v1Error(w, "unknown category: "+req.Category, http.StatusBadRequest) return } if len(req.Readings) == 0 { v1JSON(w, map[string]any{"created": 0, "skipped": 0, "errors": []string{}}) return } // Find or create category root (depth 1) rootID, err := ensureRoot(authID, req.DossierID, catInt) if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } created, skipped := 0, 0 var errors []string groups := map[string]string{} // metric → entryID for _, rd := range req.Readings { if rd.Metric == "" { errors = append(errors, "reading missing metric") continue } if rd.Timestamp == 0 { errors = append(errors, rd.Metric+": missing timestamp") continue } // Find or create group container (depth 2) groupID, exists := groups[rd.Metric] if !exists { groupID, err = ensureGroup(authID, req.DossierID, catInt, rootID, rd.Metric, rd.Unit, rd.Source) if err != nil { errors = append(errors, fmt.Sprintf("%s: %v", rd.Metric, err)) continue } groups[rd.Metric] = groupID } // Dedup: same parent + same timestamp + type=reading → skip existing, _ := lib.EntryRead(authID, req.DossierID, &lib.Filter{ Category: catInt, Type: "reading", ParentID: groupID, FromDate: rd.Timestamp, ToDate: rd.Timestamp + 1, }) if len(existing) > 0 { skipped++ continue } // Create reading (depth 3) valueStr := fmt.Sprintf("%g", rd.Value) summary := valueStr if rd.Unit != "" { summary += " " + rd.Unit } data, _ := json.Marshal(map[string]string{"unit": rd.Unit, "source": rd.Source}) reading := &lib.Entry{ DossierID: req.DossierID, ParentID: groupID, Category: catInt, Type: "reading", Value: valueStr, Summary: summary, Timestamp: rd.Timestamp, Data: string(data), } if err := lib.EntryWrite(authID, reading); err != nil { errors = append(errors, fmt.Sprintf("%s@%d: %v", rd.Metric, rd.Timestamp, err)) continue } created++ } v1JSON(w, map[string]any{"created": created, "skipped": skipped, "errors": errors}) } // ensureRoot finds or creates the category root entry (depth 1). func ensureRoot(authID, dossierID string, cat int) (string, error) { entries, err := lib.EntryRead(authID, dossierID, &lib.Filter{ Category: cat, Type: "root", ParentID: dossierID, }) if err != nil { return "", err } if len(entries) > 0 { return entries[0].EntryID, nil } id := deterministicID(dossierID, "cat", fmt.Sprintf("%d", cat)) err = lib.EntryWrite(authID, &lib.Entry{ EntryID: id, DossierID: dossierID, ParentID: dossierID, Category: cat, Type: "root", }) return id, err } // ensureGroup finds or creates a group container (depth 2). func ensureGroup(authID, dossierID string, cat int, rootID, metric, unit, source string) (string, error) { entries, err := lib.EntryRead(authID, dossierID, &lib.Filter{ Category: cat, Type: metric, ParentID: rootID, }) if err != nil { return "", err } if len(entries) > 0 { return entries[0].EntryID, nil } id := deterministicID(dossierID, "group", fmt.Sprintf("%d", cat), metric) data, _ := json.Marshal(map[string]string{"unit": unit, "source": source}) err = lib.EntryWrite(authID, &lib.Entry{ EntryID: id, DossierID: dossierID, ParentID: rootID, Category: cat, Type: metric, Summary: metricLabel(metric), Data: string(data), }) return id, err } func deterministicID(parts ...string) string { h := sha256.Sum256([]byte(strings.Join(parts, ":"))) return fmt.Sprintf("%016x", h[:8]) } func metricLabel(s string) string { words := strings.Split(strings.ReplaceAll(s, "_", " "), " ") for i, w := range words { if len(w) > 0 { words[i] = strings.ToUpper(w[:1]) + w[1:] } } return strings.Join(words, " ") }