203 lines
5.0 KiB
Go
203 lines
5.0 KiB
Go
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
|
|
}
|
|
|
|
// Fail fast: check write access before doing any work
|
|
if !lib.CheckAccess(authID, req.DossierID, "", lib.PermWrite) {
|
|
v1Error(w, "Access denied: no write permission for dossier "+req.DossierID, http.StatusForbidden)
|
|
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, " ")
|
|
}
|