package main import ( "encoding/json" "net/http" "strconv" "strings" "time" "inou/lib" ) // --- AUTH & HELPERS --- func v1Auth(r *http.Request) (string, error) { var tokenStr string // Try Authorization header first auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { tokenStr = strings.TrimPrefix(auth, "Bearer ") } // Fall back to query parameter (for Grok and other tools that can't set headers) if tokenStr == "" { tokenStr = r.URL.Query().Get("token") } if tokenStr == "" { return "", nil } // Try encrypted token first (short-lived, from /api/v1/token) token, err := lib.TokenParse(tokenStr) if err == nil { return token.DossierID, nil } // Try session token (long-lived API token from dashboard) if d := lib.DossierGetBySessionToken(tokenStr); d != nil { return d.DossierID, nil } return "", nil // No valid auth } func v1AuthRequired(w http.ResponseWriter, r *http.Request) (string, bool) { dossierID, err := v1Auth(r) if err != nil { v1Error(w, err.Error(), http.StatusUnauthorized) return "", false } if dossierID == "" { v1Error(w, "Authorization required", http.StatusUnauthorized) return "", false } return dossierID, true } func v1CanAccess(authID, targetID string) bool { if authID == targetID { return true } records, _ := lib.AccessList(&lib.AccessFilter{ AccessorID: authID, TargetID: targetID, Status: intPtr(1), }) return len(records) > 0 } func intPtr(i int) *int { return &i } func v1Error(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func v1JSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } // --- TOKEN --- // v1Token issues a short-lived access token // POST /api/v1/token - uses dossier ID from Authorization header func v1Token(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 } // Verify dossier exists (nil ctx - v1 API has own auth) d, err := lib.DossierGet(nil, authID) if err != nil || d == nil { v1Error(w, "Invalid dossier", http.StatusUnauthorized) return } // Create 4-hour token token := lib.TokenCreate(authID, 4*time.Hour) expiresIn := 4 * 60 * 60 // seconds v1JSON(w, map[string]any{ "token": token, "expires_in": expiresIn, "dossier_id": authID, }) } // --- DOSSIERS --- func v1Dossiers(w http.ResponseWriter, r *http.Request) { authID, ok := v1AuthRequired(w, r) if !ok { return } // Get dossiers this user can access (deduplicated) access, _ := lib.AccessList(&lib.AccessFilter{AccessorID: authID, Status: intPtr(1)}) seen := map[string]bool{authID: true} targetIDs := []string{authID} for _, a := range access { if !seen[a.TargetDossierID] { seen[a.TargetDossierID] = true targetIDs = append(targetIDs, a.TargetDossierID) } } sexStr := map[int]string{1: "male", 2: "female", 9: "other"} var result []map[string]any for _, tid := range targetIDs { d, err := lib.DossierGet(nil, tid) // nil ctx - v1 API has own auth if err != nil { continue } // Get available categories for this dossier categories := getDossierCategories(tid) result = append(result, map[string]any{ "id": d.DossierID, "name": d.Name, "date_of_birth": d.DateOfBirth, "sex": sexStr[d.Sex], "categories": categories, "self": tid == authID, }) } v1JSON(w, result) } func getDossierCategories(dossierID string) []string { // Query distinct categories for this dossier var counts []struct { Category int `db:"category"` Count int `db:"cnt"` } lib.Query("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? AND category > 0 GROUP BY category", []any{dossierID}, &counts) categoryNames := map[int]string{ 1: "imaging", 2: "documents", 3: "labs", 4: "genome", } categories := []string{} // Empty slice, not nil for _, c := range counts { if name, ok := categoryNames[c.Category]; ok && c.Count > 0 { categories = append(categories, name) } } return categories } func v1Dossier(w http.ResponseWriter, r *http.Request, id string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, id) { v1Error(w, "Access denied", http.StatusForbidden) return } d, err := lib.DossierGet(nil, id) // nil ctx - v1 API has own auth if err != nil { v1Error(w, "Dossier not found", http.StatusNotFound) return } v1JSON(w, map[string]any{ "id": d.DossierID, "name": d.Name, }) } // --- ENTRIES --- func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } q := r.URL.Query() parentID := q.Get("parent") category := 0 if cat := q.Get("category"); cat != "" { category = lib.CategoryFromString[cat] } filter := &lib.EntryFilter{ DossierID: dossierID, Type: q.Get("type"), SearchKey: q.Get("search_key"), } if from := q.Get("from"); from != "" { filter.FromDate, _ = strconv.ParseInt(from, 10, 64) } if to := q.Get("to"); to != "" { filter.ToDate, _ = strconv.ParseInt(to, 10, 64) } if limit := q.Get("limit"); limit != "" { filter.Limit, _ = strconv.Atoi(limit) } entries, err := lib.EntryList(lib.SystemAccessorID, parentID, category, filter) // nil ctx - v1 API has own auth if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } var result []map[string]any for _, e := range entries { entry := map[string]any{ "id": e.EntryID, "parent_id": e.ParentID, "category": lib.CategoryKey(e.Category), "type": e.Type, "summary": e.Summary, "ordinal": e.Ordinal, "timestamp": e.Timestamp, } result = append(result, entry) } // If listing children of a series, include contact_sheet_url if parentID != "" { parent, err := lib.EntryGet(nil, parentID) // nil ctx - v1 API has own auth if err == nil && parent.Type == "series" { v1JSON(w, map[string]any{ "slices": result, "contact_sheet_url": "https://inou.com/contact-sheet.webp/" + parentID + "?token=" + dossierID, }) return } } v1JSON(w, result) } func v1Entry(w http.ResponseWriter, r *http.Request, dossierID, entryID string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } e, err := lib.EntryGet(nil, entryID) // nil ctx - v1 API has own auth if err != nil || e.DossierID != dossierID { v1Error(w, "Entry not found", http.StatusNotFound) return } detail := r.URL.Query().Get("detail") entry := map[string]any{ "id": e.EntryID, "parent_id": e.ParentID, "category": lib.CategoryKey(e.Category), "type": e.Type, "summary": e.Summary, "ordinal": e.Ordinal, "timestamp": e.Timestamp, "tags": e.Tags, } if detail == "full" && e.Data != "" { var data map[string]any json.Unmarshal([]byte(e.Data), &data) entry["data"] = data } // Get children children, _ := lib.EntryList(lib.SystemAccessorID, entryID, 0, &lib.EntryFilter{ DossierID: e.DossierID, }) // nil ctx - v1 API has own auth if len(children) > 0 { var childList []map[string]any for _, c := range children { child := map[string]any{ "id": c.EntryID, "type": c.Type, "summary": c.Summary, "ordinal": c.Ordinal, } childList = append(childList, child) } entry["children"] = childList } v1JSON(w, entry) } // --- ACCESS --- func v1Access(w http.ResponseWriter, r *http.Request, dossierID string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } records, err := lib.AccessList(&lib.AccessFilter{TargetID: dossierID, Status: intPtr(1)}) if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } var result []map[string]any for _, a := range records { result = append(result, map[string]any{ "accessor_id": a.AccessorDossierID, "relation": a.Relation, "is_care_receiver": a.IsCareReceiver, "can_edit": a.CanEdit, }) } v1JSON(w, result) } // --- AUDIT --- func v1Audit(w http.ResponseWriter, r *http.Request, dossierID string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } q := r.URL.Query() filter := &lib.AuditFilter{TargetID: dossierID} if action := q.Get("action"); action != "" { filter.Action = action } if from := q.Get("from"); from != "" { filter.FromDate, _ = strconv.ParseInt(from, 10, 64) } if to := q.Get("to"); to != "" { filter.ToDate, _ = strconv.ParseInt(to, 10, 64) } if limit := q.Get("limit"); limit != "" { filter.Limit, _ = strconv.Atoi(limit) } entries, err := lib.AuditList(filter) if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } var result []map[string]any for _, a := range entries { result = append(result, map[string]any{ "id": a.AuditID, "action": a.Action, "actor_id": a.Actor1ID, "target_id": a.TargetID, "timestamp": a.Timestamp, "details": a.Details, }) } v1JSON(w, result) } // --- PARSE (LLM triage + extraction) --- type ParseRequest struct { Input string `json:"input"` } type ParseEntryResponse struct { ID string `json:"id"` Category string `json:"category"` Type string `json:"type"` Value string `json:"value"` Timestamp int64 `json:"timestamp"` } type ParsePromptResponse struct { ID string `json:"id"` Category string `json:"category"` Type string `json:"type"` Question string `json:"question"` InputType string `json:"input_type"` InputConfig any `json:"input_config,omitempty"` NextAsk int64 `json:"next_ask"` } type ParseResponse struct { Category string `json:"category"` Type string `json:"type"` Entries []ParseEntryResponse `json:"entries"` Prompt *ParsePromptResponse `json:"prompt,omitempty"` Error string `json:"error,omitempty"` } // POST /api/v1/dossiers/{id}/parse // Parses free-form health input, creates entries and prompts, returns what happened func v1Parse(w http.ResponseWriter, r *http.Request, dossierID string) { if r.Method != "POST" { v1Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } var req ParseRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { v1Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Input == "" { v1Error(w, "input is required", http.StatusBadRequest) return } // Run triage + extraction generated, err := callLLMForPrompt(req.Input, dossierID) if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } // If triage returned an error (not_health_related, no_medical_advice, etc.) if generated.Error != "" { v1JSON(w, ParseResponse{ Error: generated.Error, }) return } resp := ParseResponse{ Category: generated.Category, Type: generated.Type, Entries: []ParseEntryResponse{}, } now := time.Now() // Create entries from extracted data for _, entryData := range generated.Entries { entryJSON, _ := json.Marshal(entryData.Data) entry := &lib.Entry{ DossierID: dossierID, Category: lib.CategoryFromString[generated.Category], Type: generated.Type, Value: entryData.Value, Data: string(entryJSON), Timestamp: now.Unix(), } if err := lib.EntryAdd(entry); err != nil { continue // Skip failed entries but don't fail the whole request } resp.Entries = append(resp.Entries, ParseEntryResponse{ ID: entry.EntryID, Category: generated.Category, Type: generated.Type, Value: entryData.Value, Timestamp: entry.Timestamp, }) } // Create prompt if there's a follow-up question with schedule if generated.Question != "" && len(generated.Schedule) > 0 { nextAsk := calculateNextAskFromSchedule(generated.Schedule, now) scheduleJSON, _ := json.Marshal(generated.Schedule) inputConfigJSON, _ := json.Marshal(generated.InputConfig) prompt := &lib.Prompt{ DossierID: dossierID, Category: generated.Category, Type: generated.Type, Question: generated.Question, Schedule: string(scheduleJSON), InputType: generated.InputType, InputConfig: string(inputConfigJSON), SourceInput: req.Input, NextAsk: nextAsk, Active: true, } // Pre-fill last response from initial extracted data if len(generated.Entries) > 0 && generated.Entries[0].Data != nil { initialData, _ := json.Marshal(generated.Entries[0].Data) prompt.LastResponse = string(initialData) prompt.LastResponseRaw = generated.Entries[0].Value prompt.LastResponseAt = now.Unix() } if err := lib.PromptAdd(prompt); err == nil { var inputConfig any json.Unmarshal([]byte(prompt.InputConfig), &inputConfig) resp.Prompt = &ParsePromptResponse{ ID: prompt.PromptID, Category: prompt.Category, Type: prompt.Type, Question: prompt.Question, InputType: prompt.InputType, InputConfig: inputConfig, NextAsk: prompt.NextAsk, } } } v1JSON(w, resp) } // --- PROMPTS --- func v1Prompts(w http.ResponseWriter, r *http.Request, dossierID string) { authID, ok := v1AuthRequired(w, r) if !ok { return } if !v1CanAccess(authID, dossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } q := r.URL.Query() filter := &lib.PromptFilter{DossierID: dossierID} if cat := q.Get("category"); cat != "" { filter.Category = cat } if typ := q.Get("type"); typ != "" { filter.Type = typ } if q.Get("active") == "true" { filter.ActiveOnly = true } prompts, err := lib.PromptList(filter) if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } var result []map[string]any for _, p := range prompts { result = append(result, map[string]any{ "id": p.PromptID, "category": p.Category, "type": p.Type, "question": p.Question, "active": p.Active, "dismissed": p.Dismissed, "time_of_day": p.TimeOfDay, }) } v1JSON(w, result) } // --- IMAGE --- func v1Image(w http.ResponseWriter, r *http.Request, id string) { // Image endpoint accepts token as query param for browser compatibility tokenStr := r.URL.Query().Get("token") if tokenStr == "" { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { tokenStr = strings.TrimPrefix(auth, "Bearer ") } } if tokenStr == "" { v1Error(w, "Authorization required", http.StatusUnauthorized) return } // Try encrypted token first, fallback to raw dossier ID var authDossierID string token, err := lib.TokenParse(tokenStr) if err == nil { authDossierID = token.DossierID } else if len(tokenStr) == 16 { authDossierID = tokenStr } else { v1Error(w, err.Error(), http.StatusUnauthorized) return } // Load entry to verify access (nil ctx - v1 API has own auth) e, err := lib.EntryGet(nil, id) if err != nil { v1Error(w, "Image not found", http.StatusNotFound) return } if !v1CanAccess(authDossierID, e.DossierID) { v1Error(w, "Access denied", http.StatusForbidden) return } // Parse window/level options q := r.URL.Query() opts := &lib.ImageOpts{} if wc := q.Get("wc"); wc != "" { opts.WC, _ = strconv.ParseFloat(wc, 64) } if ww := q.Get("ww"); ww != "" { opts.WW, _ = strconv.ParseFloat(ww, 64) } data, err := lib.ImageGet(nil, id, opts) // nil ctx - v1 API has own auth if err != nil { v1Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/png") w.Write(data) } // --- CATEGORIES --- func v1Categories(w http.ResponseWriter, r *http.Request) { authID, _ := v1Auth(r) lang := lib.DossierLanguage(authID) cats := lib.Categories() var result []map[string]any for _, c := range cats { result = append(result, map[string]any{ "id": c.ID, "key": c.Name, "name": lib.CategoryTranslate(c.ID, lang), "types": c.Types, }) } v1JSON(w, result) } func v1CategoryTypes(w http.ResponseWriter, r *http.Request, category string) { types, ok := lib.CategoryTypes[category] if !ok { v1Error(w, "Category not found", http.StatusNotFound) return } v1JSON(w, types) } // --- HEALTH --- type HealthResponse struct { Status string `json:"status"` Time int64 `json:"time"` Version string `json:"version"` Checks map[string]string `json:"checks"` } func v1Health(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { v1Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } checks := make(map[string]string) allOK := true if err := lib.DBPing(); err != nil { checks["db"] = "error: " + err.Error() allOK = false } else { checks["db"] = "ok" } status := "ok" httpStatus := http.StatusOK if !allOK { status = "degraded" httpStatus = http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache, no-store") w.WriteHeader(httpStatus) json.NewEncoder(w).Encode(HealthResponse{ Status: status, Time: time.Now().Unix(), Version: Version, Checks: checks, }) } // --- ROUTER --- func v1Router(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1") parts := strings.Split(strings.Trim(path, "/"), "/") switch { // GET /health (public, no auth) case len(parts) == 1 && parts[0] == "health" && r.Method == "GET": v1Health(w, r) // POST /token case len(parts) == 1 && parts[0] == "token" && r.Method == "POST": v1Token(w, r) // GET /dossiers case len(parts) == 1 && parts[0] == "dossiers" && r.Method == "GET": v1Dossiers(w, r) // GET /dossiers/{id} case len(parts) == 2 && parts[0] == "dossiers" && r.Method == "GET": v1Dossier(w, r, parts[1]) // GET /dossiers/{id}/entries case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "entries" && r.Method == "GET": v1Entries(w, r, parts[1]) // GET /dossiers/{id}/entries/{entry_id} case len(parts) == 4 && parts[0] == "dossiers" && parts[2] == "entries" && r.Method == "GET": v1Entry(w, r, parts[1], parts[3]) // GET /dossiers/{id}/access case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "access" && r.Method == "GET": v1Access(w, r, parts[1]) // GET /dossiers/{id}/audit case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "audit" && r.Method == "GET": v1Audit(w, r, parts[1]) // GET /dossiers/{id}/prompts case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "prompts" && r.Method == "GET": v1Prompts(w, r, parts[1]) // POST /dossiers/{id}/parse case len(parts) == 3 && parts[0] == "dossiers" && parts[2] == "parse" && r.Method == "POST": v1Parse(w, r, parts[1]) // GET /images/{id} case len(parts) == 2 && parts[0] == "images" && r.Method == "GET": v1Image(w, r, parts[1]) // GET /categories case len(parts) == 1 && parts[0] == "categories" && r.Method == "GET": v1Categories(w, r) // GET /categories/{name}/types case len(parts) == 3 && parts[0] == "categories" && parts[2] == "types" && r.Method == "GET": v1CategoryTypes(w, r, parts[1]) default: v1Error(w, "Not found", http.StatusNotFound) } } func RegisterV1Routes() { http.HandleFunc("/api/v1/", v1Router) }