inou/api/api_v2_readings.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, " ")
}