From 35e9e2a84b418bcd81458338f5d93a6e89b52a17 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 8 Feb 2026 04:59:59 -0500 Subject: [PATCH] feat: add Terms of Service page and legal page updates - Add /legal/terms with comprehensive ToS content - Add terms link to footer navigation - Add /legal/terms to defense.go whitelist for external access - Update privacy policy and DPA templates with improved styling - Refactor RBAC editor template formatting - Add prompts AI setup documentation - Include database migration scripts Co-Authored-By: Claude Sonnet 4.5 --- api/api_studies.go | 4 +- api/api_v1.go | 12 +- docs/prompts-ai-setup.md | 119 +++++++ import-dicom/main.go | 20 +- lib/access.go | 284 +++++++--------- lib/normalize.go | 5 + lib/roles.go | 136 +++++--- lib/types.go | 1 + lib/v2.go | 73 +++- migrations/001_add_search_key.sql | 55 +++ portal/defense.go | 1 + portal/main.go | 144 ++++---- portal/mcp_http.go | 20 +- portal/mcp_tools.go | 5 +- portal/prompts.go | 16 +- portal/templates/base.tmpl | 2 + portal/templates/dossier.tmpl | 21 +- portal/templates/dpa.tmpl | 7 +- portal/templates/edit_rbac.tmpl | 270 ++++++++------- portal/templates/footer.tmpl | 1 + portal/templates/privacy.tmpl | 18 +- portal/templates/prompts.tmpl | 534 ++++++++++++++++++++++-------- portal/templates/terms.tmpl | 185 +++++++++++ 23 files changed, 1329 insertions(+), 604 deletions(-) create mode 100644 docs/prompts-ai-setup.md create mode 100644 migrations/001_add_search_key.sql create mode 100644 portal/templates/terms.tmpl diff --git a/api/api_studies.go b/api/api_studies.go index ff6cee1..a8e70ef 100644 --- a/api/api_studies.go +++ b/api/api_studies.go @@ -61,8 +61,8 @@ func handleStudies(w http.ResponseWriter, r *http.Request) { return } - // List all studies directly (top-level entries with type="study") - entries, err := lib.EntryRootsByType(dossierID, "study") + // List all studies (category=imaging, type=study) + entries, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/api/api_v1.go b/api/api_v1.go index 41ba7c2..bed6c8e 100644 --- a/api/api_v1.go +++ b/api/api_v1.go @@ -219,7 +219,9 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) { category = lib.CategoryFromString[cat] } filter := &lib.EntryFilter{ - Type: q.Get("type"), + DossierID: dossierID, + Type: q.Get("type"), + SearchKey: q.Get("search_key"), } if from := q.Get("from"); from != "" { filter.FromDate, _ = strconv.ParseInt(from, 10, 64) @@ -237,12 +239,8 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) { return } - // Filter to this dossier var result []map[string]any for _, e := range entries { - if e.DossierID != dossierID { - continue - } entry := map[string]any{ "id": e.EntryID, "parent_id": e.ParentID, @@ -305,7 +303,9 @@ func v1Entry(w http.ResponseWriter, r *http.Request, dossierID, entryID string) } // Get children - children, _ := lib.EntryList(lib.SystemAccessorID, entryID, 0, nil) // nil ctx - v1 API has own auth + 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 { diff --git a/docs/prompts-ai-setup.md b/docs/prompts-ai-setup.md new file mode 100644 index 0000000..5c571e5 --- /dev/null +++ b/docs/prompts-ai-setup.md @@ -0,0 +1,119 @@ +# Enabling AI Prompt Generation + +The Daily Check-in system can use AI to automatically: +- Parse freeform text ("I take vitamin D every morning") +- Create structured entries (medications, vitals, exercise, etc.) +- Generate daily tracking prompts with pre-filled values +- Learn patterns from repeated entries + +## Current Status + +✅ Code is ready and deployed +❌ Gemini API key not configured + +## Setup Instructions + +### 1. Get a Gemini API Key + +1. Go to https://aistudio.google.com/app/apikey +2. Create a new API key (free tier available) +3. Copy the key + +### 2. Configure on Staging + +```bash +# On staging server (192.168.1.253) +echo "GEMINI_API_KEY=YOUR_KEY_HERE" > /tank/inou/api/anthropic.env + +# Restart API service +ssh johan@192.168.1.253 "sudo systemctl restart inou-api" +# Or use the standard restart: +ssh johan@192.168.1.253 "/tank/inou/stop.sh && /tank/inou/start.sh" +``` + +### 3. Configure on Production + +```bash +# On production server (192.168.100.2) +echo "GEMINI_API_KEY=YOUR_KEY_HERE" > /tank/inou/api/anthropic.env + +# Restart API service +ssh johan@192.168.100.2 "sudo systemctl restart inou-api" +``` + +### 4. Verify + +Check the API logs to confirm the key loaded: + +```bash +ssh johan@192.168.1.253 "tail -20 /tank/inou/logs/api.log | grep -i gemini" +``` + +You should see: `Gemini API key loaded.` + +## Testing + +Once configured, test by entering freeform text on the prompts page: + +**Test inputs:** +- "I take vitamin D 5000 IU every morning" +- "Blood pressure 120/80" +- "Walked 30 minutes today" + +**Expected behavior:** +1. Entry is created immediately +2. System analyzes the pattern +3. If it detects a trackable pattern, it creates a new daily prompt +4. You see a notification: "✓ Saved! Added new tracking prompt." +5. The new prompt appears on the page for tomorrow + +## How It Works + +### Architecture + +``` +User Input → Triage (category detection) → Extraction (structure) → Create entries + prompts +``` + +### Prompts + +AI prompts are stored in `/tank/inou/prompts/`: +- `triage.md` - Determines category (medication, vital, exercise, etc.) +- `extract_*.md` - Category-specific extraction (one per category) + +### Code + +- `api/api_llm.go` - Main LLM integration +- `api/api_prompts.go:192` - `tryGeneratePromptFromFreeform()` entry point +- `lib/llm.go` - Gemini API wrapper + +## Cost Estimate + +Gemini 1.5 Flash (used by default): +- **Free tier**: 15 requests/minute, 1500 requests/day +- **Paid tier**: $0.00001875/1K characters input, $0.000075/1K characters output + +For typical use (10-20 prompts/day), free tier is sufficient. + +## Alternative: Claude API + +The code can be adapted to use Claude instead of Gemini. Would need: +1. Replace `lib.CallGemini()` with Claude API calls +2. Update prompt formatting (Claude uses different message format) +3. Set `ANTHROPIC_API_KEY` instead + +## Fallback Without AI + +If no API key is configured: +- Freeform entries still work (saves as plain notes) +- No automatic prompt generation +- Users can manually create prompts via the UI (not yet implemented) +- Or add prompts directly to database + +## Next Steps + +1. **Get API key** - Start with Gemini free tier +2. **Test on staging** - Verify prompt generation works +3. **Monitor usage** - Check if free tier is sufficient +4. **Consider Claude** - If Gemini isn't accurate enough +5. **Build manual prompt creator** - Fallback for users without AI diff --git a/import-dicom/main.go b/import-dicom/main.go index 162b312..800ba75 100644 --- a/import-dicom/main.go +++ b/import-dicom/main.go @@ -443,18 +443,15 @@ func calculateStepSize(requestedSpacingMM, sliceThicknessMM float64) int { // STUDY/SERIES/SLICE CREATION (using V2 API) // ============================================================================ -// getOrCreateStudy finds existing study or creates new one (root-level entry) +// getOrCreateStudy finds existing study or creates new one (child of imaging category root) func getOrCreateStudy(data []byte, dossierID string) (string, error) { studyUID := readStringTag(data, 0x0020, 0x000D) if id, ok := studyCache[studyUID]; ok { return id, nil } - // Query for existing study using V2 API - studies, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryImaging, &lib.EntryFilter{ // nil ctx - import tool - DossierID: dossierID, - Type: "study", - }) + // Query for existing study by category+type (parent-agnostic) + studies, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study") if err == nil { for _, s := range studies { if s.Value == studyUID { @@ -464,6 +461,12 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) { } } + // Get imaging category root (create if needed) + catRootID, err := lib.EnsureCategoryRoot(dossierID, lib.CategoryImaging) + if err != nil { + return "", fmt.Errorf("ensure imaging category root: %w", err) + } + // Extract study metadata patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010)) studyDesc := readStringTag(data, 0x0008, 0x1030) @@ -500,7 +503,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) { e := &lib.Entry{ EntryID: lib.NewID(), DossierID: dossierID, - ParentID: "", // root-level entry + ParentID: catRootID, // child of imaging category root Category: lib.CategoryImaging, Type: "study", Value: studyUID, @@ -508,7 +511,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) { Timestamp: time.Now().Unix(), Data: string(dataJSON), } - if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool + if err := lib.EntryWrite(nil, e); err != nil { return "", err } studyCache[studyUID] = e.EntryID @@ -573,6 +576,7 @@ func getOrCreateSeries(data []byte, dossierID, studyID string) (string, error) { Timestamp: time.Now().Unix(), Tags: seriesDesc, Data: string(dataJSON), + SearchKey: modality, } if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool return "", err diff --git a/lib/access.go b/lib/access.go index 77b4a85..03e8268 100644 --- a/lib/access.go +++ b/lib/access.go @@ -7,24 +7,25 @@ import ( ) // ============================================================================ -// RBAC Access Control - Entry-based permission system +// RBAC Access Control // ============================================================================ // -// Three-level hierarchy: -// 1. Root (entry_id = "") - "all" or "nothing" -// 2. Categories - entries that are category roots (parent_id = "") -// 3. Individual entries - access flows down via parent_id chain +// Grants live at three levels: +// 1. Root (entry_id = "") — applies to all data +// 2. Category — grant on a category/category_root entry +// 3. Entry-specific — grant on an individual entry (rare) // -// Operations: -// r = read - view data -// w = write - create/update data -// d = delete - remove data -// m = manage - grant/revoke access to others -// "" = explicit denial (removes inherited access) +// Operations: r=read, w=write, d=delete, m=manage // -// Categories are just entries - no special handling. -// Access to parent implies access to all children. +// Resolved once per accessor+dossier (cached until permissions change): +// rootOps — ops from root grant +// categoryOps[cat] — ops from category-level grants +// hasChildGrants[cat] — true if entry-specific grants exist in this category // +// Access check (hot path, 99% of cases = zero DB lookups): +// 1. categoryOps[cat] exists, no child grants → return it +// 2. categoryOps[cat] exists, has child grants → check entry, fall back to category +// 3. rootOps // ============================================================================ // AccessContext represents who is making the request @@ -34,78 +35,55 @@ type AccessContext struct { } // SystemContext is used for internal operations that bypass RBAC -// Initialized in ConfigInit() with SystemAccessorID from config var SystemContext *AccessContext -// ErrAccessDenied is returned when permission check fails var ErrAccessDenied = fmt.Errorf("access denied") - -// ErrNoAccessor is returned when AccessorID is empty and IsSystem is false var ErrNoAccessor = fmt.Errorf("no accessor specified") // ============================================================================ // Permission Cache // ============================================================================ -type cacheEntry struct { - ops string // "r", "rw", "rwd", "rwdm" - expiresAt time.Time +type resolvedGrants struct { + rootOps string // ops for root grant (entry_id="") + categoryOps map[int]string // category → ops + hasChildGrants map[int]bool // category → has entry-specific grants? + entryOps map[string]string // entry_id → ops (only for rare entry-level grants) } type permissionCache struct { mu sync.RWMutex - cache map[string]map[string]map[string]*cacheEntry // [accessor][dossier][entry_id] -> ops - ttl time.Duration + cache map[string]map[string]*resolvedGrants // [accessor][dossier] } var permCache = &permissionCache{ - cache: make(map[string]map[string]map[string]*cacheEntry), - ttl: time.Hour, + cache: make(map[string]map[string]*resolvedGrants), } -// get returns cached ops or empty string if not found/expired -func (c *permissionCache) get(accessorID, dossierID, entryID string) string { +func (c *permissionCache) get(accessorID, dossierID string) *resolvedGrants { c.mu.RLock() defer c.mu.RUnlock() - if c.cache[accessorID] == nil { - return "" + return nil } - if c.cache[accessorID][dossierID] == nil { - return "" - } - entry := c.cache[accessorID][dossierID][entryID] - if entry == nil || time.Now().After(entry.expiresAt) { - return "" - } - return entry.ops + return c.cache[accessorID][dossierID] } -// set stores ops in cache -func (c *permissionCache) set(accessorID, dossierID, entryID, ops string) { +func (c *permissionCache) set(accessorID, dossierID string, rg *resolvedGrants) { c.mu.Lock() defer c.mu.Unlock() - if c.cache[accessorID] == nil { - c.cache[accessorID] = make(map[string]map[string]*cacheEntry) - } - if c.cache[accessorID][dossierID] == nil { - c.cache[accessorID][dossierID] = make(map[string]*cacheEntry) - } - c.cache[accessorID][dossierID][entryID] = &cacheEntry{ - ops: ops, - expiresAt: time.Now().Add(c.ttl), + c.cache[accessorID] = make(map[string]*resolvedGrants) } + c.cache[accessorID][dossierID] = rg } -// InvalidateCacheForAccessor clears all cached permissions for an accessor func InvalidateCacheForAccessor(accessorID string) { permCache.mu.Lock() defer permCache.mu.Unlock() delete(permCache.cache, accessorID) } -// InvalidateCacheForDossier clears all cached permissions for a dossier func InvalidateCacheForDossier(dossierID string) { permCache.mu.Lock() defer permCache.mu.Unlock() @@ -114,128 +92,123 @@ func InvalidateCacheForDossier(dossierID string) { } } -// InvalidateCacheAll clears entire cache func InvalidateCacheAll() { permCache.mu.Lock() defer permCache.mu.Unlock() - permCache.cache = make(map[string]map[string]map[string]*cacheEntry) + permCache.cache = make(map[string]map[string]*resolvedGrants) } // ============================================================================ -// Core Permission Check (used by v2.go functions) +// Core Permission Check // ============================================================================ -// checkAccess is the internal permission check called by v2.go data functions. -// Returns nil if allowed, ErrAccessDenied if not. -// -// Parameters: -// accessorID - who is asking (empty = system/internal) -// dossierID - whose data -// entryID - specific entry (empty = root level) -// op - operation: 'r', 'w', 'd', 'm' -// -// Algorithm: -// 1. System accessor → allow (internal operations with audit trail) -// 2. Accessor == owner → allow (full access to own data) -// 3. Check grants (entry-specific → parent chain → root) -// 4. No grant → deny -func checkAccess(accessorID, dossierID, entryID string, op rune) error { - // 1. System accessor = internal operation (explicit backdoor for audit) +// checkAccess checks if accessor can perform op on dossier/entry. +// category: entry's category if known (0 = look up from entryID if needed) +func checkAccess(accessorID, dossierID, entryID string, category int, op rune) error { if accessorID == SystemAccessorID { return nil } - - // 2. Owner has full access to own data if accessorID == dossierID { return nil } - - // 3. Check grants - ops := getEffectiveOps(accessorID, dossierID, entryID) - if hasOp(ops, op) { + if hasOp(getEffectiveOps(accessorID, dossierID, entryID, category), op) { return nil } - - // 4. No grant found - deny return ErrAccessDenied } -// CheckAccess is the exported version for use by API/Portal code. +// CheckAccess is the exported version (category unknown). func CheckAccess(accessorID, dossierID, entryID string, op rune) error { - return checkAccess(accessorID, dossierID, entryID, op) + return checkAccess(accessorID, dossierID, entryID, 0, op) } -// getEffectiveOps returns the ops string for accessor on dossier/entry -// Uses cache, falls back to database lookup -func getEffectiveOps(accessorID, dossierID, entryID string) string { - // Check cache first - if ops := permCache.get(accessorID, dossierID, entryID); ops != "" { - return ops +// getEffectiveOps returns ops for accessor on dossier/entry. +// category >0 avoids a DB lookup to determine the entry's category. +func getEffectiveOps(accessorID, dossierID, entryID string, category int) string { + rg := resolveGrants(accessorID, dossierID) + + if entryID != "" { + // Determine category + cat := category + if cat == 0 { + if e, err := entryGetRaw(entryID); err == nil && e != nil { + cat = e.Category + } + } + + if cat > 0 { + catOps, hasCat := rg.categoryOps[cat] + + // 99% path: category grant, no child grants → done + if hasCat && !rg.hasChildGrants[cat] { + return catOps + } + + // Rare: entry-specific grants exist in this category + if rg.hasChildGrants[cat] { + if ops, ok := rg.entryOps[entryID]; ok { + return ops + } + // Fall back to category grant + if hasCat { + return catOps + } + } + } + } + + return rg.rootOps +} + +// resolveGrants loads grants for accessor+dossier, resolves each into +// root/category/entry buckets. Cached until permissions change. +func resolveGrants(accessorID, dossierID string) *resolvedGrants { + if rg := permCache.get(accessorID, dossierID); rg != nil { + return rg + } + + rg := &resolvedGrants{ + categoryOps: make(map[int]string), + hasChildGrants: make(map[int]bool), + entryOps: make(map[string]string), } - // Load grants from database (bypasses RBAC - internal function) grants, err := accessGrantListRaw(&PermissionFilter{ DossierID: dossierID, GranteeID: accessorID, }) if err != nil || len(grants) == 0 { - // Cache negative result - permCache.set(accessorID, dossierID, entryID, "") - return "" + permCache.set(accessorID, dossierID, rg) + return rg } - // Find most specific matching grant - ops := findMatchingOps(grants, entryID) - permCache.set(accessorID, dossierID, entryID, ops) - return ops -} - -// findMatchingOps finds the most specific grant that applies to entryID -// Priority: entry-specific > parent chain > root -// Categories are just entries - no special handling needed -func findMatchingOps(grants []*Access, entryID string) string { - // Build entry->ops map for quick lookup - grantMap := make(map[string]string) // entry_id -> ops (empty key = root) for _, g := range grants { - existing := grantMap[g.EntryID] - // Merge ops (keep most permissive) - grantMap[g.EntryID] = mergeOps(existing, g.Ops) - } - - // 1. Check entry-specific grant (including category entries) - if entryID != "" { - if ops, ok := grantMap[entryID]; ok { - return ops + if g.EntryID == "" { + rg.rootOps = mergeOps(rg.rootOps, g.Ops) + continue } - // 2. Walk up parent chain (using raw function to avoid RBAC recursion) - currentID := entryID - for i := 0; i < 100; i++ { // max depth to prevent infinite loops - entry, err := entryGetRaw(currentID) - if err != nil || entry == nil { - break - } - if entry.ParentID == "" { - break - } - // Check parent for grant - if ops, ok := grantMap[entry.ParentID]; ok { - return ops - } - currentID = entry.ParentID + entry, err := entryGetRaw(g.EntryID) + if err != nil || entry == nil { + continue + } + + if entry.Type == "category" || entry.Type == "category_root" { + rg.categoryOps[entry.Category] = mergeOps(rg.categoryOps[entry.Category], g.Ops) + } else { + rg.entryOps[g.EntryID] = mergeOps(rg.entryOps[g.EntryID], g.Ops) + rg.hasChildGrants[entry.Category] = true } } - // 3. Check root grant (entry_id = "" means "all") - if ops, ok := grantMap[""]; ok { - return ops - } - - // 4. No grant found - return "" + permCache.set(accessorID, dossierID, rg) + return rg } -// mergeOps combines two ops strings, keeping the most permissive +// ============================================================================ +// Helpers +// ============================================================================ + func mergeOps(a, b string) string { ops := make(map[rune]bool) for _, c := range a { @@ -253,7 +226,6 @@ func mergeOps(a, b string) string { return result } -// hasOp checks if ops string contains the requested operation func hasOp(ops string, op rune) bool { for _, c := range ops { if c == op { @@ -263,7 +235,6 @@ func hasOp(ops string, op rune) bool { return false } -// accessGrantListRaw loads grants without RBAC check (for internal use by permission system) func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) { q := "SELECT * FROM access WHERE 1=1" args := []any{} @@ -298,49 +269,41 @@ func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) { // Utility Functions // ============================================================================ -// EnsureCategoryEntry creates a category entry if it doesn't exist -// Returns the entry_id of the category entry -func EnsureCategoryEntry(dossierID string, category int) (string, error) { - // Check if category entry already exists (use empty string for system context) +// EnsureCategoryRoot finds or creates the root entry for a category in a dossier. +// This entry serves as parent for all entries of that category and as the +// target for RBAC category-level grants. +func EnsureCategoryRoot(dossierID string, category int) (string, error) { + // Look for existing category_root entry entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{ DossierID: dossierID, - Type: "category", + Type: "category_root", Limit: 1, }) - if err != nil { - return "", err - } - if len(entries) > 0 { + if err == nil && len(entries) > 0 { return entries[0].EntryID, nil } - // Create category entry + // Create category root entry entry := &Entry{ DossierID: dossierID, Category: category, - Type: "category", + Type: "category_root", Value: CategoryName(category), - ParentID: "", // Categories are root-level } - if err := EntryWrite(SystemContext, entry); err != nil { + if err := EntryWrite(nil, entry); err != nil { return "", err } return entry.EntryID, nil } -// CanAccessDossier returns true if accessor can read dossier (for quick checks) func CanAccessDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'r') == nil } -// CanManageDossier returns true if accessor can manage permissions for dossier func CanManageDossier(accessorID, dossierID string) bool { return CheckAccess(accessorID, dossierID, "", 'm') == nil } -// GrantAccess creates an access grant -// If entryID is empty, grants root-level access -// If entryID is a category, ensures category entry exists first func GrantAccess(dossierID, granteeID, entryID, ops string) error { grant := &Access{ DossierID: dossierID, @@ -356,9 +319,7 @@ func GrantAccess(dossierID, granteeID, entryID, ops string) error { return err } -// RevokeAccess removes an access grant func RevokeAccess(accessID string) error { - // Get the grant to know which accessor to invalidate var grant Access if err := Load("access", accessID, &grant); err != nil { return err @@ -370,8 +331,6 @@ func RevokeAccess(accessID string) error { return err } -// GetAccessorOps returns the operations accessor can perform on dossier/entry -// Returns empty string if no access func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { @@ -379,41 +338,32 @@ func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string { } return "" } - - // Owner has full access if ctx.AccessorID == dossierID { return "rwdm" } - - return getEffectiveOps(ctx.AccessorID, dossierID, entryID) + return getEffectiveOps(ctx.AccessorID, dossierID, entryID, 0) } -// DossierListAccessible returns all dossiers accessible by ctx.AccessorID func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) { if ctx == nil || ctx.AccessorID == "" { if ctx != nil && ctx.IsSystem { - // System context: return all return DossierList(nil, nil) } return nil, ErrNoAccessor } - // Get accessor's own dossier own, err := dossierGetRaw(ctx.AccessorID) if err != nil { - // Invalid accessor (doesn't exist) - treat as unauthorized return nil, ErrAccessDenied } result := []*Dossier{own} - // Get all grants where accessor is grantee grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID}) if err != nil { - return result, nil // Return just own dossier on error + return result, nil } - // Collect unique dossier IDs with read permission seen := map[string]bool{ctx.AccessorID: true} for _, g := range grants { if g.DossierID == "" || seen[g.DossierID] { diff --git a/lib/normalize.go b/lib/normalize.go index 321f125..644f2c0 100644 --- a/lib/normalize.go +++ b/lib/normalize.go @@ -162,6 +162,11 @@ func Normalize(dossierID string, category int) error { b, _ := json.Marshal(data) e.Data = string(b) + // Update SearchKey with LOINC code (encrypted) + if norm.Loinc != "" { + e.SearchKey = norm.Loinc + } + // Rebuild Summary: "Abbr: value unit" unit, _ := data["unit"].(string) summary := norm.Abbr + ": " + e.Value diff --git a/lib/roles.go b/lib/roles.go index e718659..decbbdd 100644 --- a/lib/roles.go +++ b/lib/roles.go @@ -24,47 +24,128 @@ type RoleTemplate struct { // SystemRoles defines all available role templates var SystemRoles = []RoleTemplate{ { - Name: "Family", - Description: "Full access for family members", + Name: "Parent/Guardian", + Description: "Full access to child's data", Grants: []RoleGrant{ - {Category: 0, Ops: "rwdm"}, // Full access to everything + {Category: 0, Ops: "rwdm"}, + }, + }, + { + Name: "Spouse/Partner", + Description: "Full access to partner's data", + Grants: []RoleGrant{ + {Category: 0, Ops: "rwdm"}, + }, + }, + { + Name: "Sibling", + Description: "View and add notes", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, + {Category: CategoryNote, Ops: "rw"}, + }, + }, + { + Name: "Extended Family", + Description: "View only", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, }, }, { Name: "Doctor", - Description: "Read/write access for healthcare providers", + Description: "Full clinical access", Grants: []RoleGrant{ - {Category: 0, Ops: "rw"}, // Read/write to everything + {Category: 0, Ops: "rw"}, + }, + }, + { + Name: "Specialist", + Description: "Clinical data read, write imaging/labs/docs", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, + {Category: CategoryImaging, Ops: "rw"}, + {Category: CategoryLab, Ops: "rw"}, + {Category: CategoryDocument, Ops: "rw"}, + {Category: CategoryAssessment, Ops: "rw"}, }, }, { Name: "Caregiver", - Description: "Read/write access for caregivers", + Description: "Daily care access", Grants: []RoleGrant{ - {Category: 0, Ops: "rw"}, // Read/write to everything + {Category: 0, Ops: "rw"}, + }, + }, + { + Name: "Physical Therapist", + Description: "Exercise, vitals, imaging", + Grants: []RoleGrant{ + {Category: CategoryImaging, Ops: "r"}, + {Category: CategoryLab, Ops: "r"}, + {Category: CategoryVital, Ops: "rw"}, + {Category: CategoryExercise, Ops: "rw"}, + {Category: CategoryNote, Ops: "rw"}, + }, + }, + { + Name: "Nutritionist", + Description: "Diet, supplements, labs", + Grants: []RoleGrant{ + {Category: CategoryLab, Ops: "r"}, + {Category: CategoryVital, Ops: "r"}, + {Category: CategoryNutrition, Ops: "rw"}, + {Category: CategorySupplement, Ops: "rw"}, }, }, { Name: "Trainer", - Description: "Read-only with write access to exercise and nutrition", + Description: "Exercise, nutrition, vitals", Grants: []RoleGrant{ - {Category: 0, Ops: "r"}, // Read everything - {Category: CategoryExercise, Ops: "rw"}, // Write exercise - {Category: CategoryNutrition, Ops: "rw"}, // Write nutrition + {Category: CategoryVital, Ops: "r"}, + {Category: CategoryExercise, Ops: "rw"}, + {Category: CategoryNutrition, Ops: "rw"}, + }, + }, + { + Name: "Therapist", + Description: "Mental health, notes, assessments", + Grants: []RoleGrant{ + {Category: CategoryLab, Ops: "r"}, + {Category: CategoryVital, Ops: "r"}, + {Category: CategoryNote, Ops: "rw"}, + {Category: CategorySymptom, Ops: "rw"}, + {Category: CategoryAssessment, Ops: "rw"}, + }, + }, + { + Name: "Pharmacist", + Description: "Medications, labs, genome", + Grants: []RoleGrant{ + {Category: CategoryMedication, Ops: "r"}, + {Category: CategoryLab, Ops: "r"}, + {Category: CategoryGenome, Ops: "r"}, }, }, { Name: "Friend", - Description: "Read-only access", + Description: "View only", Grants: []RoleGrant{ - {Category: 0, Ops: "r"}, // Read everything + {Category: 0, Ops: "r"}, }, }, { Name: "Researcher", - Description: "Read-only access for research purposes", + Description: "View only for research", Grants: []RoleGrant{ - {Category: 0, Ops: "r"}, // Read everything + {Category: 0, Ops: "r"}, + }, + }, + { + Name: "Emergency", + Description: "Emergency read access", + Grants: []RoleGrant{ + {Category: 0, Ops: "r"}, }, }, } @@ -135,30 +216,9 @@ func ApplyRoleTemplate(dossierID, granteeID, roleName string) error { return nil } -// findOrCreateCategoryRoot finds or creates a root entry for category-level grants -// This is a virtual entry that serves as parent for all entries of that category +// findOrCreateCategoryRoot is an alias for EnsureCategoryRoot (in access.go) func findOrCreateCategoryRoot(dossierID string, category int) (string, error) { - // Look for existing category root entry (type = "category_root", use empty string for system context) - entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{ - DossierID: dossierID, - Type: "category_root", - Limit: 1, - }) - if err == nil && len(entries) > 0 { - return entries[0].EntryID, nil - } - - // Create virtual category root entry - entry := &Entry{ - DossierID: dossierID, - Category: category, - Type: "category_root", - Value: CategoryName(category), - } - if err := EntryWrite(nil, entry); err != nil { - return "", err - } - return entry.EntryID, nil + return EnsureCategoryRoot(dossierID, category) } // RevokeRole removes all grants with the specified role for a grantee on a dossier diff --git a/lib/types.go b/lib/types.go index 8a032db..5020310 100644 --- a/lib/types.go +++ b/lib/types.go @@ -328,6 +328,7 @@ type Entry struct { Status int `db:"status"` Tags string `db:"tags"` Data string `db:"data"` + SearchKey string `db:"search_key"` // LOINC (labs), gene (genome), modality (imaging) - encrypted } // Audit represents an audit log entry diff --git a/lib/v2.go b/lib/v2.go index 2f595db..a9eaa66 100644 --- a/lib/v2.go +++ b/lib/v2.go @@ -43,6 +43,7 @@ type EntryFilter struct { DossierID string Type string Value string + SearchKey string FromDate int64 ToDate int64 Limit int @@ -60,7 +61,7 @@ func EntryWrite(ctx *AccessContext, entries ...*Entry) error { return fmt.Errorf("entry missing dossier_id") } // Check write on parent (or root if no parent) - if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, 'w'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, e.Category, 'w'); err != nil { return err } } @@ -84,7 +85,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error { if err != nil { continue // Entry doesn't exist, skip } - if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'd'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'd'); err != nil { return err } } @@ -94,7 +95,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error { // EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root. func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error { // RBAC: Check delete permission on dossier root - if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil { return err } @@ -118,7 +119,7 @@ func EntryGet(ctx *AccessContext, id string) (*Entry, error) { } // RBAC: Check read permission - if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil { return nil, err } @@ -147,7 +148,7 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) ( } } if dossierID != "" { - if err := checkAccess(accessorID, dossierID, parent, 'r'); err != nil { + if err := checkAccess(accessorID, dossierID, parent, category, 'r'); err != nil { return nil, err } } @@ -180,6 +181,10 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) ( q += " AND value = ?" args = append(args, CryptoEncrypt(f.Value)) } + if f.SearchKey != "" { + q += " AND search_key = ?" + args = append(args, CryptoEncrypt(f.SearchKey)) + } if f.FromDate > 0 { q += " AND timestamp >= ?" args = append(args, f.FromDate) @@ -219,7 +224,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error { for _, d := range dossiers { if d.DossierID != "" { // Update - need manage permission (unless creating own or system) - if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 'm'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 0, 'm'); err != nil { return err } } @@ -245,7 +250,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error { func DossierRemove(ctx *AccessContext, ids ...string) error { // RBAC: Check manage permission for each dossier for _, id := range ids { - if err := checkAccess(accessorIDFromContext(ctx), id, "", 'm'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'm'); err != nil { return err } } @@ -255,7 +260,7 @@ func DossierRemove(ctx *AccessContext, ids ...string) error { // DossierGet retrieves a dossier. Requires read permission. func DossierGet(ctx *AccessContext, id string) (*Dossier, error) { // RBAC: Check read permission - if err := checkAccess(accessorIDFromContext(ctx), id, "", 'r'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'r'); err != nil { return nil, err } @@ -552,7 +557,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) { } // RBAC: Check read permission - if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil { return nil, err } @@ -637,7 +642,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) { // ObjectWrite encrypts and writes data to the object store. Requires write permission. func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error { // RBAC: Check write permission - if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'w'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'w'); err != nil { return err } @@ -652,7 +657,7 @@ func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) err // ObjectRead reads and decrypts data from the object store. Requires read permission. func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) { // RBAC: Check read permission - if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'r'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'r'); err != nil { return nil, err } @@ -675,7 +680,7 @@ func objectReadRaw(dossierID, entryID string) ([]byte, error) { // ObjectRemove deletes an object from the store. Requires delete permission. func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error { // RBAC: Check delete permission - if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'd'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'd'); err != nil { return err } return os.Remove(ObjectPath(dossierID, entryID)) @@ -684,7 +689,7 @@ func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error { // ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission. func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error { // RBAC: Check delete permission on dossier root - if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil { + if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil { return err } return os.RemoveAll(filepath.Join(ObjectDir, dossierID)) @@ -841,6 +846,48 @@ func MigrateOldAccess() int { return migrated } +// MigrateStudiesToCategoryRoot moves orphan studies (parent_id="") under their +// imaging category_root entry. Idempotent — skips studies already parented. +func MigrateStudiesToCategoryRoot() int { + // Find all imaging entries with empty parent_id, filter to studies in Go + var all []*Entry + err := Query( + "SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')", + []any{CategoryImaging}, &all) + if err != nil { + return 0 + } + var studies []*Entry + for _, e := range all { + if e.Type == "study" { + studies = append(studies, e) + } + } + if len(studies) == 0 { + return 0 + } + + migrated := 0 + catRoots := map[string]string{} // dossier_id → category_root entry_id + + for _, s := range studies { + rootID, ok := catRoots[s.DossierID] + if !ok { + rootID, err = EnsureCategoryRoot(s.DossierID, CategoryImaging) + if err != nil { + continue + } + catRoots[s.DossierID] = rootID + } + + s.ParentID = rootID + if err := Save("entries", s); err == nil { + migrated++ + } + } + return migrated +} + // AccessGrantGet retrieves a single access grant by ID func AccessGrantGet(id string) (*Access, error) { a := &Access{} diff --git a/migrations/001_add_search_key.sql b/migrations/001_add_search_key.sql new file mode 100644 index 0000000..c508812 --- /dev/null +++ b/migrations/001_add_search_key.sql @@ -0,0 +1,55 @@ +-- Migration: Add search_key column and optimize indices +-- Date: 2026-02-07 +-- Purpose: Enable fast LOINC/gene/modality search for MCP API + +-- ============================================================================ +-- 1. Add search_key column +-- ============================================================================ + +ALTER TABLE entries ADD COLUMN search_key TEXT; + +-- ============================================================================ +-- 2. Drop redundant indices +-- ============================================================================ + +-- Redundant with idx_entries_search_key (which covers dossier_id, category) +DROP INDEX IF EXISTS idx_entries_dossier; + +-- Redundant with idx_entries_search_key (which covers dossier_id, category, search_key) +DROP INDEX IF EXISTS idx_entries_dossier_category; + +-- Redundant after code fix to always include dossier_id in parent queries +DROP INDEX IF EXISTS idx_entries_parent; + +-- ============================================================================ +-- 3. Add new index for search_key +-- ============================================================================ + +CREATE INDEX idx_entries_search_key +ON entries(dossier_id, category, search_key); + +-- ============================================================================ +-- 4. Backfill search_key from existing data +-- ============================================================================ + +-- Labs: Extract LOINC from data.loinc +UPDATE entries +SET search_key = json_extract(data, '$.loinc') +WHERE category = 3 + AND search_key IS NULL + AND json_extract(data, '$.loinc') IS NOT NULL; + +-- Genome: Extract gene from data.gene +UPDATE entries +SET search_key = json_extract(data, '$.gene') +WHERE category = 4 + AND search_key IS NULL + AND json_extract(data, '$.gene') IS NOT NULL; + +-- ============================================================================ +-- Final index state: +-- ============================================================================ +-- 1. sqlite_autoindex_entries_1 (entry_id) PRIMARY KEY +-- 2. idx_entries_dossier_parent (dossier_id, parent_id) KEPT +-- 3. idx_entries_search_key (dossier_id, category, search_key) NEW +-- ============================================================================ diff --git a/portal/defense.go b/portal/defense.go index 73e6db0..d376970 100644 --- a/portal/defense.go +++ b/portal/defense.go @@ -57,6 +57,7 @@ var validPaths = []string{ "/privacy-policy", "/security", "/legal/dpa", + "/legal/terms", "/demo", "/oauth/authorize", "/oauth/token", diff --git a/portal/main.go b/portal/main.go index 60cea7f..1568aee 100644 --- a/portal/main.go +++ b/portal/main.go @@ -131,6 +131,7 @@ type PageData struct { EntryGrants []EntryGrant // RBAC edit page CategoriesRBAC []CategoryRBACView + SelectedRole string // Dossier: unified sections Sections []DossierSection LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh} @@ -738,6 +739,11 @@ func handleDPA(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p}) } + +func handleTerms(w http.ResponseWriter, r *http.Request) { + p := getLoggedInDossier(r) + render(w, r, PageData{Page: "terms", Lang: getLang(r), Dossier: p}) +} func handleStyleguide(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p}) @@ -1435,13 +1441,12 @@ type RoleView struct { // CategoryRBACView represents a category with per-operation permissions type CategoryRBACView struct { - ID int - Name string - Description string - CanRead bool - CanWrite bool - CanDelete bool - CanManage bool + ID int + Name string + CanRead bool + CanWrite bool + CanDelete bool + CanManage bool } func handlePermissions(w http.ResponseWriter, r *http.Request) { @@ -1643,35 +1648,18 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) { } if action == "update" { - // Build base ops from checkboxes - baseOps := "" - if r.FormValue("op_r") == "1" { baseOps += "r" } - if r.FormValue("op_w") == "1" { baseOps += "w" } - if r.FormValue("op_d") == "1" { baseOps += "d" } - if r.FormValue("op_m") == "1" { baseOps += "m" } + roleName := r.FormValue("role") + if roleName == "" { roleName = "Custom" } // Clear existing grants lib.AccessRevokeAll(targetID, granteeID) - // Create root grant if base ops specified - if baseOps != "" { - lib.AccessGrantWrite(&lib.Access{ - DossierID: targetID, - GranteeID: granteeID, - EntryID: "", - Role: "Custom", - Ops: baseOps, - }) - } - - // Create category-specific grants - allCats := []int{ - lib.CategoryImaging, lib.CategoryDocument, lib.CategoryLab, - lib.CategoryGenome, lib.CategoryVital, lib.CategoryMedication, - lib.CategorySupplement, lib.CategoryExercise, lib.CategorySymptom, - } - - for _, catID := range allCats { + // Create per-category grants (all categories except All=0 and Upload=5) + for _, cat := range lib.Categories() { + if cat.ID == lib.CategoryUpload { + continue + } + catID := cat.ID catOps := "" if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" } if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" } @@ -1679,14 +1667,13 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) { if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" } if catOps != "" { - // Ensure category entry exists - entryID, err := lib.EnsureCategoryEntry(targetID, catID) + entryID, err := lib.EnsureCategoryRoot(targetID, catID) if err == nil { lib.AccessGrantWrite(&lib.Access{ DossierID: targetID, GranteeID: granteeID, EntryID: entryID, - Role: "Custom", + Role: roleName, Ops: catOps, }) } @@ -1694,7 +1681,7 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) { } lib.InvalidateCacheForAccessor(granteeID) - lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", baseOps, 0) + lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0) http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther) return } @@ -1703,63 +1690,43 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) { // GET: Load current grants and build view grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID}) - // Parse grants to determine permissions - hasRead, hasWrite, hasDelete, hasManage := false, false, false, false + // Parse grants to determine per-category permissions and detect role catPerms := make(map[int]map[rune]bool) // catID -> op -> bool - + selectedRole := "Custom" for _, g := range grants { + if g.Role != "" && selectedRole == "Custom" { + selectedRole = g.Role + } else if g.Role != "" && g.Role != selectedRole { + selectedRole = "Custom" + } if g.EntryID == "" { - // Root grant - applies to base permissions - for _, op := range g.Ops { - switch op { - case 'r': hasRead = true - case 'w': hasWrite = true - case 'd': hasDelete = true - case 'm': hasManage = true - } + continue // Root grants not shown in per-category view + } + entry, err := lib.EntryGet(nil, g.EntryID) + if err == nil && entry != nil && (entry.Type == "category" || entry.Type == "category_root") { + if catPerms[entry.Category] == nil { + catPerms[entry.Category] = make(map[rune]bool) } - } else { - // Entry-specific grant - find which category - entry, err := lib.EntryGet(nil, g.EntryID) - if err == nil && entry != nil && entry.Type == "category" { - if catPerms[entry.Category] == nil { - catPerms[entry.Category] = make(map[rune]bool) - } - for _, op := range g.Ops { - catPerms[entry.Category][op] = true - } + for _, op := range g.Ops { + catPerms[entry.Category][op] = true } } } - // Build category RBAC views - categoryDefs := []struct { - ID int - Name string - Desc string - }{ - {lib.CategoryImaging, "Imaging", "MRI, CT, X-rays, DICOM studies"}, - {lib.CategoryDocument, "Documents", "PDFs, reports, letters"}, - {lib.CategoryLab, "Labs", "Blood tests, lab results"}, - {lib.CategoryGenome, "Genome", "Genetic data, variants"}, - {lib.CategoryVital, "Vitals", "Weight, temperature, blood pressure"}, - {lib.CategoryMedication, "Medications", "Prescriptions and dosages"}, - {lib.CategorySupplement, "Supplements", "Vitamins and supplements"}, - {lib.CategoryExercise, "Exercise", "Workouts and physical activity"}, - {lib.CategorySymptom, "Symptoms", "Health observations and notes"}, - } - + // Build category RBAC views (all categories except All=0 and Upload=5) var categoriesRBAC []CategoryRBACView - for _, def := range categoryDefs { - perms := catPerms[def.ID] + for _, cat := range lib.Categories() { + if cat.ID == lib.CategoryUpload { + continue + } + perms := catPerms[cat.ID] categoriesRBAC = append(categoriesRBAC, CategoryRBACView{ - ID: def.ID, - Name: def.Name, - Description: def.Desc, - CanRead: perms['r'], - CanWrite: perms['w'], - CanDelete: perms['d'], - CanManage: perms['m'], + ID: cat.ID, + Name: cat.Name, + CanRead: perms['r'], + CanWrite: perms['w'], + CanDelete: perms['d'], + CanManage: perms['m'], }) } @@ -1787,12 +1754,9 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) { TargetDossier: target, GranteeID: granteeID, GranteeName: grantee.Name, - HasRead: hasRead, - HasWrite: hasWrite, - HasDelete: hasDelete, - HasManage: hasManage, CategoriesRBAC: categoriesRBAC, Roles: roles, + SelectedRole: selectedRole, Success: successMsg, } @@ -2026,6 +1990,8 @@ func setupMux() http.Handler { mux.HandleFunc("/security", handleSecurity) mux.HandleFunc("/legal/dpa", handleDPA) mux.HandleFunc("/legal/dpa/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/dpa", http.StatusMovedPermanently) }) + mux.HandleFunc("/legal/terms", handleTerms) + mux.HandleFunc("/legal/terms/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/terms", http.StatusMovedPermanently) }) mux.HandleFunc("/styleguide", handleStyleguide) mux.HandleFunc("/demo", handleDemo) mux.HandleFunc("/dossier/add", handleAddDossier) @@ -2103,6 +2069,10 @@ func main() { if n := lib.MigrateOldAccess(); n > 0 { fmt.Printf("Migrated %d access grants from dossier_access\n", n) } + // Migrate orphan studies to imaging category root (idempotent) + if n := lib.MigrateStudiesToCategoryRoot(); n > 0 { + fmt.Printf("Migrated %d studies to imaging category root\n", n) + } loadTranslations() lib.TranslateInit("lang") // also init lib translations for CategoryTranslate diff --git a/portal/mcp_http.go b/portal/mcp_http.go index c9be514..aa9ff0c 100644 --- a/portal/mcp_http.go +++ b/portal/mcp_http.go @@ -378,17 +378,18 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) { }, { "name": "query_entries", - "description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: LOINC codes (e.g., '2947-0') provide best accuracy. Use get_categories first to discover available categories.", + "description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: Use search_key with LOINC code (e.g., '2947-0') for fast, accurate results. Use get_categories first to discover available categories.", "inputSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, - "category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"}, - "type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., LOINC code for labs)"}, - "parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"}, - "from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"}, - "to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"}, - "limit": map[string]interface{}{"type": "number", "description": "Maximum results"}, + "dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, + "category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"}, + "type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., test name for labs)"}, + "search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs (e.g., '2947-0'), gene name for genome (e.g., 'MTHFR')"}, + "parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"}, + "from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"}, + "to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"}, + "limit": map[string]interface{}{"type": "number", "description": "Maximum results"}, }, "required": []string{"dossier"}, }, @@ -526,11 +527,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss } category, _ := params.Arguments["category"].(string) typ, _ := params.Arguments["type"].(string) + searchKey, _ := params.Arguments["search_key"].(string) parent, _ := params.Arguments["parent"].(string) from, _ := params.Arguments["from"].(string) to, _ := params.Arguments["to"].(string) limit, _ := params.Arguments["limit"].(float64) - result, err := mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to, int(limit)) + result, err := mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to, int(limit)) if err != nil { sendMCPError(w, req.ID, -32000, err.Error()) return diff --git a/portal/mcp_tools.go b/portal/mcp_tools.go index dc4259f..5d3a909 100644 --- a/portal/mcp_tools.go +++ b/portal/mcp_tools.go @@ -143,7 +143,7 @@ func mcpFetchContactSheet(accessToken, dossier, series string, wc, ww float64) ( return mcpImageContent(b64, "image/webp", fmt.Sprintf("Contact sheet %s (%d bytes)", series[:8], len(body))), nil } -func mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to string, limit int) (string, error) { +func mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to string, limit int) (string, error) { params := map[string]string{} if category != "" { params["category"] = category @@ -151,6 +151,9 @@ func mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to strin if typ != "" { params["type"] = typ } + if searchKey != "" { + params["search_key"] = searchKey + } if parent != "" { params["parent"] = parent } diff --git a/portal/prompts.go b/portal/prompts.go index 92cfc02..a88ebe7 100644 --- a/portal/prompts.go +++ b/portal/prompts.go @@ -121,12 +121,26 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) { } json.NewDecoder(resp.Body).Decode(&apiPrompts) + // Helper to translate category name + translateCategory := func(cat string) string { + switch cat { + case "supplement": return T(lang, "section_supplements") + case "medication": return T(lang, "section_medications") + case "vital": return T(lang, "section_vitals") + case "exercise": return T(lang, "section_exercise") + case "symptom": return T(lang, "section_symptoms") + case "nutrition": return T(lang, "section_nutrition") + case "note": return T(lang, "section_notes") + default: return cat + } + } + // Convert to view models var prompts []PromptView for _, ap := range apiPrompts { pv := PromptView{ ID: ap.ID, - Category: ap.Category, + Category: translateCategory(ap.Category), Type: ap.Type, Question: ap.Question, NextAsk: ap.NextAsk, diff --git a/portal/templates/base.tmpl b/portal/templates/base.tmpl index 4010d06..980d785 100644 --- a/portal/templates/base.tmpl +++ b/portal/templates/base.tmpl @@ -28,6 +28,7 @@ Privacy Policy Security DPA + Terms {{if .Dossier}}Connect{{end}} @@ -104,6 +105,7 @@ {{else if eq .Page "privacy"}}{{template "privacy" .}} {{else if eq .Page "security"}}{{template "security" .}} {{else if eq .Page "dpa"}}{{template "dpa" .}} + {{else if eq .Page "terms"}}{{template "terms" .}} {{else if eq .Page "styleguide"}}{{template "styleguide" .}} {{else if eq .Page "pricing"}}{{template "pricing" .}} {{else if eq .Page "faq"}}{{template "faq" .}} diff --git a/portal/templates/dossier.tmpl b/portal/templates/dossier.tmpl index 15ba752..cd37842 100644 --- a/portal/templates/dossier.tmpl +++ b/portal/templates/dossier.tmpl @@ -316,20 +316,31 @@ function renderFilterChart(card, table, q) { return; } + // Calculate global time range across all charts for alignment + let globalTMin = Infinity, globalTMax = -Infinity; + for (const [, s] of chartable) { + s.points.sort((a, b) => a.date - b.date); + if (s.points.length > 0) { + globalTMin = Math.min(globalTMin, s.points[0].date.getTime()); + globalTMax = Math.max(globalTMax, s.points[s.points.length - 1].date.getTime()); + } + } + // Extend to today if last point is in the past + globalTMax = Math.max(globalTMax, new Date().getTime()); + wrapper.style.display = ''; wrapper.classList.remove('collapsed'); let html = ''; for (const [loinc, s] of chartable) { - s.points.sort((a, b) => a.date - b.date); // Build display name: "Full Name (Abbr)" or fallback to abbreviation const fullName = loincNames[loinc] || s.abbr; const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr; - html += buildSVGChart(displayName, s.unit, s.points, s.abbr); + html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax); } body.innerHTML = html; } -function buildSVGChart(name, unit, points, abbr) { +function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) { const W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 }; const pw = W - PAD.left - PAD.right; const ph = H - PAD.top - PAD.bottom; @@ -349,7 +360,9 @@ function buildSVGChart(name, unit, points, abbr) { // Never show negative Y when all values are >= 0 if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin); - const tMin = points[0].date.getTime(), tMax = Math.max(new Date().getTime(), points[points.length-1].date.getTime()); + // Use global time range if provided, otherwise fall back to local range + const tMin = globalTMin !== undefined ? globalTMin : points[0].date.getTime(); + const tMax = globalTMax !== undefined ? globalTMax : Math.max(new Date().getTime(), points[points.length-1].date.getTime()); const tRange = tMax - tMin || 1; const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw; diff --git a/portal/templates/dpa.tmpl b/portal/templates/dpa.tmpl index aa45c94..1434fb5 100644 --- a/portal/templates/dpa.tmpl +++ b/portal/templates/dpa.tmpl @@ -127,7 +127,7 @@

Data we process

Health data.

-

Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.

+

Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload. Genetic and genomic data constitutes special category data under GDPR Article 9 and is processed solely on the basis of your explicit consent.

Account data.

Name, email address, date of birth, and sex. Used for account management and medical context.

@@ -238,11 +238,12 @@

Contact

+

Data Protection Officer: Johan Jongsma

Questions about data processing: privacy@inou.com

-

This agreement was last updated on January 21, 2026.

+

This agreement was last updated on February 8, 2026.

{{template "footer"}} -{{end}} +{{end}} \ No newline at end of file diff --git a/portal/templates/edit_rbac.tmpl b/portal/templates/edit_rbac.tmpl index 762d5e1..6d0ddf0 100644 --- a/portal/templates/edit_rbac.tmpl +++ b/portal/templates/edit_rbac.tmpl @@ -1,7 +1,7 @@ {{define "edit_rbac"}}
-
-
+
+

Edit permissions

@@ -15,95 +15,54 @@
+ -
-
- -

Quick presets for common access patterns

- -
-
- - -
-

Base Permissions

-

Operations that apply across all data

-
- - - - -
-
- - -
-

Category Permissions

-

Fine-grained control per data type

- -
- {{range .Categories}} -
-
-
- {{.Name}} - {{.Description}} -
-
-
- - - - -
-
+
+ +
-
+ + + + + + + + + + + + + + + + + + + + + + {{range .CategoriesRBAC}} + + + + + + + + + {{end}} + +
CategoryAll
{{.Name}}
+ +
Cancel
@@ -120,49 +79,106 @@
+ + {{end}} diff --git a/portal/templates/footer.tmpl b/portal/templates/footer.tmpl index 62c0d1e..c5f617d 100644 --- a/portal/templates/footer.tmpl +++ b/portal/templates/footer.tmpl @@ -3,6 +3,7 @@ inou health diff --git a/portal/templates/privacy.tmpl b/portal/templates/privacy.tmpl index 6d17423..92571b0 100644 --- a/portal/templates/privacy.tmpl +++ b/portal/templates/privacy.tmpl @@ -147,6 +147,12 @@

Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.

+
+

Legal basis for processing

+

We process your data based on your explicit consent, given when you create your account and upload health information. For account management and security (such as login sessions and IP logging), we rely on legitimate interest in operating a secure service. You may withdraw consent at any time by deleting your account — we will stop all processing immediately.

+

Genetic and genomic data is classified as special category data under GDPR Article 9. By uploading genetic data to inou, you provide explicit consent for us to store and display it. We process this data solely to show it back to you and to transmit it to services you authorize. We do not analyze, profile, or make decisions based on your genetic information.

+
+

What we promise

@@ -192,7 +198,7 @@

Found a mistake? You can correct it yourself, or ask us to help.

Delete everything.

-

One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster — we do not offer restores of individual accounts or deleted data.

+

One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster. Backup copies are overwritten within 30 days of deletion. We do not offer restores of individual accounts or deleted data.

Take it with you.

Want to move to another service? We'll export your data in standard formats. You're never locked in.

@@ -213,6 +219,11 @@

We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.

+
+

Not a medical device

+

inou is a personal health data viewer. It is not a medical device and is not intended for clinical diagnosis, treatment, cure, or prevention of any disease or medical condition. The platform stores and displays your health data — it does not analyze, interpret, or act on it. Always consult a qualified healthcare professional for medical decisions.

+
+

Children's privacy

inou is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.

@@ -223,10 +234,11 @@

We comply with FADP (Swiss data protection), GDPR (European data protection), and HIPAA (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.

We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.

Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.

-

Questions, concerns, or requests: privacy@inou.com

+

Our Data Protection Officer is Johan Jongsma. For all privacy and data protection inquiries, contact privacy@inou.com.

+

This policy was last updated on February 8, 2026.

{{template "footer"}}
-{{end}} +{{end}} \ No newline at end of file diff --git a/portal/templates/prompts.tmpl b/portal/templates/prompts.tmpl index 555208b..00b3fe1 100644 --- a/portal/templates/prompts.tmpl +++ b/portal/templates/prompts.tmpl @@ -1,12 +1,17 @@ {{define "prompts"}}

{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in

-

Track daily measurements and observations

+

Track daily measurements and observations

+ +
+ 💡 How it works: Enter what you want to track (e.g., "I take vitamin D every morning" or "walked 30 minutes today"). + The system will learn patterns and offer to create daily reminders with pre-filled values for fast tracking. +
{{if .Error}}
{{.Error}}
{{end}} {{if .Success}}
{{.Success}}
{{end}} - {{if .Prompts}} + {{if or .DuePrompts .UpcomingPrompts .Entries}}
@@ -18,10 +23,86 @@
+ {{/* 1. FREEFORM CARD - Always visible */}} + {{range .DuePrompts}} + {{if .IsFreeform}} +
+ +
+ {{.Question}} + optional +
+
+
+ + +
+
+ or press Ctrl+Enter +
+
+ +
+ {{end}} + {{end}} + + {{/* 2. PENDING CARDS - Due but not filled yet */}} + {{range .DuePrompts}} + {{if not .IsFreeform}} + {{if not .HasResponse}} +
+ +
+ Stop tracking? + Yes + No +
+
+
+ {{.Category}} + {{.Question}} + {{.NextAskFormatted}} +
+
+ {{if .Fields}} + {{if eq (len .Fields) 1}} + {{with index .Fields 0}} + {{if eq .Type "number"}} +
+ + {{if .Unit}}{{.Unit}}{{end}} + +
+ {{else if eq .Type "checkbox"}} + + {{end}} + {{end}} + {{end}} + {{end}} +
+
+
+ {{end}} + {{end}} + {{end}} + + {{/* 3. FILLED CARDS - Entries from today */}} {{range .Entries}} -
+
+ {{.Category}} {{.Question}} {{.TimeFormatted}}
@@ -43,136 +124,78 @@ - {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} + {{if .SourceInput}}
Created from: "{{.SourceInput}}"
{{end}}
{{end}} - {{range .Prompts}} +
+
+ + {{if .UpcomingPrompts}} +
+
+
+ +
+
+ {{range .UpcomingPrompts}} {{$prompt := .}} -
- +
Stop tracking? Yes No
- - -
-
- {{.Question}} - {{.LastResponseRaw}} -
- - {{if .SourceInput}}
↳ "{{.SourceInput}}"
{{end}} +
+ {{.Category}} + {{.Question}} + {{.NextAskFormatted}}
- - - {{end}}
+ {{end}} {{else}} -
-

All caught up! No items due right now.

- View all items +
+

✨ No tracking prompts yet

+

Start by logging something below, and the system will learn your patterns.

+

Examples:

+
    +
  • "I take vitamin D 5000 IU every morning"
  • +
  • "Blood pressure 120/80"
  • +
  • "Walked 30 minutes today"
  • +
{{end}} @@ -219,6 +242,7 @@ position: relative; padding: 20px; border-bottom: 1px solid var(--border); + transition: opacity 0.3s ease; } .prompt-item:last-child { border-bottom: none; @@ -274,6 +298,18 @@ align-items: flex-start; margin-bottom: 12px; } +.prompt-category { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent); + margin-right: 8px; + padding: 2px 8px; + background: rgba(198, 93, 7, 0.1); + border-radius: 4px; +} .prompt-question { font-size: 1rem; font-weight: 500; @@ -484,8 +520,24 @@ color: var(--text-muted); font-style: italic; } -.entry-item { - background: #f9f9f9; +.prompt-freeform { + background: #fefce8; + border-left: 4px solid #eab308; +} +.prompt-pending { + background: #fff; + border-left: 4px solid var(--accent); +} +.prompt-filled { + background: #f0fdf4; + border-left: 4px solid #16a34a; +} +.prompt-preview { + opacity: 0.6; +} +.prompt-preview input[disabled] { + cursor: not-allowed; + background: #f9fafb; } .entry-readonly .prompt-field-row { display: flex; @@ -502,6 +554,64 @@ font-weight: 600; color: var(--accent); } +.btn-save { + padding: 6px 16px; + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.btn-save:hover { + background: #B45309; + transform: translateY(-1px); +} +.btn-save:active { + transform: translateY(0); +} +.btn-save.saving { + opacity: 0.6; + cursor: wait; +} +.prompt-actions { + display: flex; + gap: 12px; + align-items: center; + margin-top: 12px; +} +.prompt-hint { + font-size: 0.85rem; + color: var(--text-muted); +} +.notification { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + font-size: 0.95rem; + opacity: 0; + transform: translateY(-10px); + transition: all 0.3s ease; + z-index: 1000; +} +.notification.show { + opacity: 1; + transform: translateY(0); +} +.notification-success { + border-left: 4px solid #16a34a; +} +.notification-error { + border-left: 4px solid #dc2626; + color: #dc2626; +} @keyframes slideIn { from { @@ -517,16 +627,28 @@ {{end}} diff --git a/portal/templates/terms.tmpl b/portal/templates/terms.tmpl new file mode 100644 index 0000000..217191d --- /dev/null +++ b/portal/templates/terms.tmpl @@ -0,0 +1,185 @@ +{{define "terms"}} + + +
+ +
+

Terms of Service

+

These terms govern your use of inou. By creating an account, you agree to them. If you don't agree, don't use the service.

+
+ +
+

The service

+ +

What inou is.

+

inou is a personal health data platform. You upload your medical files — imaging, lab results, genetic data — and we store them securely so you can view and manage them. You can connect third-party tools, such as AI assistants, to access your data.

+ +

What inou is not.

+

inou is not a medical device. It is not intended for clinical diagnosis, treatment, cure, or prevention of any disease or medical condition. inou does not provide medical advice. The platform displays your data — it does not interpret it, recommend actions, or replace a qualified healthcare professional. Always consult your doctor for medical decisions.

+
+ +
+

Your account

+ +

Account requirements.

+

You must be at least 18 years old to create an account, unless a parent or guardian creates and manages the account on your behalf. You are responsible for keeping your login credentials secure. One account per person — accounts are not transferable.

+ +

Your data, your responsibility.

+

You are responsible for the accuracy and legality of the data you upload. You must have the right to store and process any files you upload. Do not upload data belonging to others without their explicit consent.

+
+ +
+

Our responsibilities

+ +

What we provide.

+

We will store your data securely using FIPS 140-3 validated encryption, make it available to you through the platform, and transmit it to third-party services you explicitly authorize. We will notify you of material changes to these terms or our privacy practices.

+ +

What we don't guarantee.

+

We aim for continuous availability but cannot guarantee it. The service may be temporarily unavailable for maintenance, updates, or circumstances beyond our control. We are not liable for decisions you or others make based on data viewed through the platform.

+
+ +
+

Acceptable use

+ +

Don't.

+

Don't attempt to access other users' data. Don't reverse-engineer, probe, or attack the platform. Don't use the service for anything illegal. Don't upload malicious files. Don't upload content unrelated to health data — this is a medical platform, not general-purpose storage. Don't share your login credentials. Don't resell access to the service.

+ +

If you do.

+

We may suspend or terminate your account. In cases of illegal activity, we will cooperate with law enforcement.

+
+ +
+

Payment

+ +

Pricing.

+

Plans and pricing are described on our pricing page. Prices may change — we will notify you in advance of any increase. Payment is handled by third-party processors. We never see or store your payment details.

+ +

Refunds.

+

If you're unhappy, contact us. We handle refunds on a case-by-case basis.

+
+ +
+

Termination

+ +

You can leave anytime.

+

Delete your account, and all your data is permanently destroyed. No notice required. No penalty.

+ +

We can end things too.

+

We may terminate your account for violation of these terms, with notice where possible. If we discontinue the service entirely, we will give you reasonable time to export your data.

+
+ +
+

Liability

+ +

Limitation of liability.

+

To the maximum extent permitted by law, inou's total liability to you for any claim arising from these terms or the service is limited to the amount you paid us in the 12 months preceding the claim. We are not liable for indirect, incidental, special, consequential, or punitive damages.

+ +

Indemnification.

+

You agree to indemnify inou against claims arising from your use of the service, your data, or your violation of these terms.

+
+ +
+

Governing law

+

These terms are governed by the laws of the State of Florida, United States. Disputes will be resolved in the courts of Florida.

+
+ +
+

Changes

+

We may update these terms. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.

+

Questions: privacy@inou.com

+

Last updated: February 8, 2026.

+
+ + {{template "footer"}} + +
+{{end}} \ No newline at end of file