796 lines
20 KiB
Go
796 lines
20 KiB
Go
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)
|
|
}
|