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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-08 04:59:59 -05:00
parent 7192f39bc1
commit 35e9e2a84b
23 changed files with 1329 additions and 604 deletions

View File

@ -61,8 +61,8 @@ func handleStudies(w http.ResponseWriter, r *http.Request) {
return return
} }
// List all studies directly (top-level entries with type="study") // List all studies (category=imaging, type=study)
entries, err := lib.EntryRootsByType(dossierID, "study") entries, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View File

@ -219,7 +219,9 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) {
category = lib.CategoryFromString[cat] category = lib.CategoryFromString[cat]
} }
filter := &lib.EntryFilter{ filter := &lib.EntryFilter{
DossierID: dossierID,
Type: q.Get("type"), Type: q.Get("type"),
SearchKey: q.Get("search_key"),
} }
if from := q.Get("from"); from != "" { if from := q.Get("from"); from != "" {
filter.FromDate, _ = strconv.ParseInt(from, 10, 64) filter.FromDate, _ = strconv.ParseInt(from, 10, 64)
@ -237,12 +239,8 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) {
return return
} }
// Filter to this dossier
var result []map[string]any var result []map[string]any
for _, e := range entries { for _, e := range entries {
if e.DossierID != dossierID {
continue
}
entry := map[string]any{ entry := map[string]any{
"id": e.EntryID, "id": e.EntryID,
"parent_id": e.ParentID, "parent_id": e.ParentID,
@ -305,7 +303,9 @@ func v1Entry(w http.ResponseWriter, r *http.Request, dossierID, entryID string)
} }
// Get children // 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 { if len(children) > 0 {
var childList []map[string]any var childList []map[string]any
for _, c := range children { for _, c := range children {

119
docs/prompts-ai-setup.md Normal file
View File

@ -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

View File

@ -443,18 +443,15 @@ func calculateStepSize(requestedSpacingMM, sliceThicknessMM float64) int {
// STUDY/SERIES/SLICE CREATION (using V2 API) // 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) { func getOrCreateStudy(data []byte, dossierID string) (string, error) {
studyUID := readStringTag(data, 0x0020, 0x000D) studyUID := readStringTag(data, 0x0020, 0x000D)
if id, ok := studyCache[studyUID]; ok { if id, ok := studyCache[studyUID]; ok {
return id, nil return id, nil
} }
// Query for existing study using V2 API // Query for existing study by category+type (parent-agnostic)
studies, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryImaging, &lib.EntryFilter{ // nil ctx - import tool studies, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
DossierID: dossierID,
Type: "study",
})
if err == nil { if err == nil {
for _, s := range studies { for _, s := range studies {
if s.Value == studyUID { 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 // Extract study metadata
patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010)) patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010))
studyDesc := readStringTag(data, 0x0008, 0x1030) studyDesc := readStringTag(data, 0x0008, 0x1030)
@ -500,7 +503,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
e := &lib.Entry{ e := &lib.Entry{
EntryID: lib.NewID(), EntryID: lib.NewID(),
DossierID: dossierID, DossierID: dossierID,
ParentID: "", // root-level entry ParentID: catRootID, // child of imaging category root
Category: lib.CategoryImaging, Category: lib.CategoryImaging,
Type: "study", Type: "study",
Value: studyUID, Value: studyUID,
@ -508,7 +511,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
Data: string(dataJSON), 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 return "", err
} }
studyCache[studyUID] = e.EntryID studyCache[studyUID] = e.EntryID
@ -573,6 +576,7 @@ func getOrCreateSeries(data []byte, dossierID, studyID string) (string, error) {
Timestamp: time.Now().Unix(), Timestamp: time.Now().Unix(),
Tags: seriesDesc, Tags: seriesDesc,
Data: string(dataJSON), Data: string(dataJSON),
SearchKey: modality,
} }
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
return "", err return "", err

View File

@ -7,24 +7,25 @@ import (
) )
// ============================================================================ // ============================================================================
// RBAC Access Control - Entry-based permission system // RBAC Access Control
// ============================================================================ // ============================================================================
// //
// Three-level hierarchy: // Grants live at three levels:
// 1. Root (entry_id = "") - "all" or "nothing" // 1. Root (entry_id = "") — applies to all data
// 2. Categories - entries that are category roots (parent_id = "") // 2. Category — grant on a category/category_root entry
// 3. Individual entries - access flows down via parent_id chain // 3. Entry-specific — grant on an individual entry (rare)
// //
// Operations: // Operations: r=read, w=write, d=delete, m=manage
// 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)
// //
// Categories are just entries - no special handling. // Resolved once per accessor+dossier (cached until permissions change):
// Access to parent implies access to all children. // 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 // AccessContext represents who is making the request
@ -34,78 +35,55 @@ type AccessContext struct {
} }
// SystemContext is used for internal operations that bypass RBAC // SystemContext is used for internal operations that bypass RBAC
// Initialized in ConfigInit() with SystemAccessorID from config
var SystemContext *AccessContext var SystemContext *AccessContext
// ErrAccessDenied is returned when permission check fails
var ErrAccessDenied = fmt.Errorf("access denied") var ErrAccessDenied = fmt.Errorf("access denied")
// ErrNoAccessor is returned when AccessorID is empty and IsSystem is false
var ErrNoAccessor = fmt.Errorf("no accessor specified") var ErrNoAccessor = fmt.Errorf("no accessor specified")
// ============================================================================ // ============================================================================
// Permission Cache // Permission Cache
// ============================================================================ // ============================================================================
type cacheEntry struct { type resolvedGrants struct {
ops string // "r", "rw", "rwd", "rwdm" rootOps string // ops for root grant (entry_id="")
expiresAt time.Time 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 { type permissionCache struct {
mu sync.RWMutex mu sync.RWMutex
cache map[string]map[string]map[string]*cacheEntry // [accessor][dossier][entry_id] -> ops cache map[string]map[string]*resolvedGrants // [accessor][dossier]
ttl time.Duration
} }
var permCache = &permissionCache{ var permCache = &permissionCache{
cache: make(map[string]map[string]map[string]*cacheEntry), cache: make(map[string]map[string]*resolvedGrants),
ttl: time.Hour,
} }
// get returns cached ops or empty string if not found/expired func (c *permissionCache) get(accessorID, dossierID string) *resolvedGrants {
func (c *permissionCache) get(accessorID, dossierID, entryID string) string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
if c.cache[accessorID] == nil { if c.cache[accessorID] == nil {
return "" return nil
} }
if c.cache[accessorID][dossierID] == nil { return c.cache[accessorID][dossierID]
return ""
}
entry := c.cache[accessorID][dossierID][entryID]
if entry == nil || time.Now().After(entry.expiresAt) {
return ""
}
return entry.ops
} }
// set stores ops in cache func (c *permissionCache) set(accessorID, dossierID string, rg *resolvedGrants) {
func (c *permissionCache) set(accessorID, dossierID, entryID, ops string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.cache[accessorID] == nil { if c.cache[accessorID] == nil {
c.cache[accessorID] = make(map[string]map[string]*cacheEntry) c.cache[accessorID] = make(map[string]*resolvedGrants)
}
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][dossierID] = rg
} }
// InvalidateCacheForAccessor clears all cached permissions for an accessor
func InvalidateCacheForAccessor(accessorID string) { func InvalidateCacheForAccessor(accessorID string) {
permCache.mu.Lock() permCache.mu.Lock()
defer permCache.mu.Unlock() defer permCache.mu.Unlock()
delete(permCache.cache, accessorID) delete(permCache.cache, accessorID)
} }
// InvalidateCacheForDossier clears all cached permissions for a dossier
func InvalidateCacheForDossier(dossierID string) { func InvalidateCacheForDossier(dossierID string) {
permCache.mu.Lock() permCache.mu.Lock()
defer permCache.mu.Unlock() defer permCache.mu.Unlock()
@ -114,128 +92,123 @@ func InvalidateCacheForDossier(dossierID string) {
} }
} }
// InvalidateCacheAll clears entire cache
func InvalidateCacheAll() { func InvalidateCacheAll() {
permCache.mu.Lock() permCache.mu.Lock()
defer permCache.mu.Unlock() 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. // checkAccess checks if accessor can perform op on dossier/entry.
// Returns nil if allowed, ErrAccessDenied if not. // category: entry's category if known (0 = look up from entryID if needed)
// func checkAccess(accessorID, dossierID, entryID string, category int, op rune) error {
// 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)
if accessorID == SystemAccessorID { if accessorID == SystemAccessorID {
return nil return nil
} }
// 2. Owner has full access to own data
if accessorID == dossierID { if accessorID == dossierID {
return nil return nil
} }
if hasOp(getEffectiveOps(accessorID, dossierID, entryID, category), op) {
// 3. Check grants
ops := getEffectiveOps(accessorID, dossierID, entryID)
if hasOp(ops, op) {
return nil return nil
} }
// 4. No grant found - deny
return ErrAccessDenied 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 { 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 // getEffectiveOps returns ops for accessor on dossier/entry.
// Uses cache, falls back to database lookup // category >0 avoids a DB lookup to determine the entry's category.
func getEffectiveOps(accessorID, dossierID, entryID string) string { func getEffectiveOps(accessorID, dossierID, entryID string, category int) string {
// Check cache first rg := resolveGrants(accessorID, dossierID)
if ops := permCache.get(accessorID, dossierID, entryID); ops != "" {
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 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{ grants, err := accessGrantListRaw(&PermissionFilter{
DossierID: dossierID, DossierID: dossierID,
GranteeID: accessorID, GranteeID: accessorID,
}) })
if err != nil || len(grants) == 0 { if err != nil || len(grants) == 0 {
// Cache negative result permCache.set(accessorID, dossierID, rg)
permCache.set(accessorID, dossierID, entryID, "") return rg
return ""
} }
// 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 { for _, g := range grants {
existing := grantMap[g.EntryID] if g.EntryID == "" {
// Merge ops (keep most permissive) rg.rootOps = mergeOps(rg.rootOps, g.Ops)
grantMap[g.EntryID] = mergeOps(existing, g.Ops) continue
} }
// 1. Check entry-specific grant (including category entries) entry, err := entryGetRaw(g.EntryID)
if entryID != "" {
if ops, ok := grantMap[entryID]; ok {
return ops
}
// 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 { if err != nil || entry == nil {
break continue
} }
if entry.ParentID == "" {
break if entry.Type == "category" || entry.Type == "category_root" {
} rg.categoryOps[entry.Category] = mergeOps(rg.categoryOps[entry.Category], g.Ops)
// Check parent for grant } else {
if ops, ok := grantMap[entry.ParentID]; ok { rg.entryOps[g.EntryID] = mergeOps(rg.entryOps[g.EntryID], g.Ops)
return ops rg.hasChildGrants[entry.Category] = true
}
currentID = entry.ParentID
} }
} }
// 3. Check root grant (entry_id = "" means "all") permCache.set(accessorID, dossierID, rg)
if ops, ok := grantMap[""]; ok { return rg
return ops
} }
// 4. No grant found // ============================================================================
return "" // Helpers
} // ============================================================================
// mergeOps combines two ops strings, keeping the most permissive
func mergeOps(a, b string) string { func mergeOps(a, b string) string {
ops := make(map[rune]bool) ops := make(map[rune]bool)
for _, c := range a { for _, c := range a {
@ -253,7 +226,6 @@ func mergeOps(a, b string) string {
return result return result
} }
// hasOp checks if ops string contains the requested operation
func hasOp(ops string, op rune) bool { func hasOp(ops string, op rune) bool {
for _, c := range ops { for _, c := range ops {
if c == op { if c == op {
@ -263,7 +235,6 @@ func hasOp(ops string, op rune) bool {
return false return false
} }
// accessGrantListRaw loads grants without RBAC check (for internal use by permission system)
func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) { func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
q := "SELECT * FROM access WHERE 1=1" q := "SELECT * FROM access WHERE 1=1"
args := []any{} args := []any{}
@ -298,49 +269,41 @@ func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
// Utility Functions // Utility Functions
// ============================================================================ // ============================================================================
// EnsureCategoryEntry creates a category entry if it doesn't exist // EnsureCategoryRoot finds or creates the root entry for a category in a dossier.
// Returns the entry_id of the category entry // This entry serves as parent for all entries of that category and as the
func EnsureCategoryEntry(dossierID string, category int) (string, error) { // target for RBAC category-level grants.
// Check if category entry already exists (use empty string for system context) func EnsureCategoryRoot(dossierID string, category int) (string, error) {
// Look for existing category_root entry
entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{ entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{
DossierID: dossierID, DossierID: dossierID,
Type: "category", Type: "category_root",
Limit: 1, Limit: 1,
}) })
if err != nil { if err == nil && len(entries) > 0 {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil return entries[0].EntryID, nil
} }
// Create category entry // Create category root entry
entry := &Entry{ entry := &Entry{
DossierID: dossierID, DossierID: dossierID,
Category: category, Category: category,
Type: "category", Type: "category_root",
Value: CategoryName(category), 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 "", err
} }
return entry.EntryID, nil return entry.EntryID, nil
} }
// CanAccessDossier returns true if accessor can read dossier (for quick checks)
func CanAccessDossier(accessorID, dossierID string) bool { func CanAccessDossier(accessorID, dossierID string) bool {
return CheckAccess(accessorID, dossierID, "", 'r') == nil return CheckAccess(accessorID, dossierID, "", 'r') == nil
} }
// CanManageDossier returns true if accessor can manage permissions for dossier
func CanManageDossier(accessorID, dossierID string) bool { func CanManageDossier(accessorID, dossierID string) bool {
return CheckAccess(accessorID, dossierID, "", 'm') == nil 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 { func GrantAccess(dossierID, granteeID, entryID, ops string) error {
grant := &Access{ grant := &Access{
DossierID: dossierID, DossierID: dossierID,
@ -356,9 +319,7 @@ func GrantAccess(dossierID, granteeID, entryID, ops string) error {
return err return err
} }
// RevokeAccess removes an access grant
func RevokeAccess(accessID string) error { func RevokeAccess(accessID string) error {
// Get the grant to know which accessor to invalidate
var grant Access var grant Access
if err := Load("access", accessID, &grant); err != nil { if err := Load("access", accessID, &grant); err != nil {
return err return err
@ -370,8 +331,6 @@ func RevokeAccess(accessID string) error {
return err 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 { func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string {
if ctx == nil || ctx.AccessorID == "" { if ctx == nil || ctx.AccessorID == "" {
if ctx != nil && ctx.IsSystem { if ctx != nil && ctx.IsSystem {
@ -379,41 +338,32 @@ func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string {
} }
return "" return ""
} }
// Owner has full access
if ctx.AccessorID == dossierID { if ctx.AccessorID == dossierID {
return "rwdm" return "rwdm"
} }
return getEffectiveOps(ctx.AccessorID, dossierID, entryID, 0)
return getEffectiveOps(ctx.AccessorID, dossierID, entryID)
} }
// DossierListAccessible returns all dossiers accessible by ctx.AccessorID
func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) { func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) {
if ctx == nil || ctx.AccessorID == "" { if ctx == nil || ctx.AccessorID == "" {
if ctx != nil && ctx.IsSystem { if ctx != nil && ctx.IsSystem {
// System context: return all
return DossierList(nil, nil) return DossierList(nil, nil)
} }
return nil, ErrNoAccessor return nil, ErrNoAccessor
} }
// Get accessor's own dossier
own, err := dossierGetRaw(ctx.AccessorID) own, err := dossierGetRaw(ctx.AccessorID)
if err != nil { if err != nil {
// Invalid accessor (doesn't exist) - treat as unauthorized
return nil, ErrAccessDenied return nil, ErrAccessDenied
} }
result := []*Dossier{own} result := []*Dossier{own}
// Get all grants where accessor is grantee
grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID}) grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID})
if err != nil { 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} seen := map[string]bool{ctx.AccessorID: true}
for _, g := range grants { for _, g := range grants {
if g.DossierID == "" || seen[g.DossierID] { if g.DossierID == "" || seen[g.DossierID] {

View File

@ -162,6 +162,11 @@ func Normalize(dossierID string, category int) error {
b, _ := json.Marshal(data) b, _ := json.Marshal(data)
e.Data = string(b) e.Data = string(b)
// Update SearchKey with LOINC code (encrypted)
if norm.Loinc != "" {
e.SearchKey = norm.Loinc
}
// Rebuild Summary: "Abbr: value unit" // Rebuild Summary: "Abbr: value unit"
unit, _ := data["unit"].(string) unit, _ := data["unit"].(string)
summary := norm.Abbr + ": " + e.Value summary := norm.Abbr + ": " + e.Value

View File

@ -24,47 +24,128 @@ type RoleTemplate struct {
// SystemRoles defines all available role templates // SystemRoles defines all available role templates
var SystemRoles = []RoleTemplate{ var SystemRoles = []RoleTemplate{
{ {
Name: "Family", Name: "Parent/Guardian",
Description: "Full access for family members", Description: "Full access to child's data",
Grants: []RoleGrant{ 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", Name: "Doctor",
Description: "Read/write access for healthcare providers", Description: "Full clinical access",
Grants: []RoleGrant{ 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", Name: "Caregiver",
Description: "Read/write access for caregivers", Description: "Daily care access",
Grants: []RoleGrant{ 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", Name: "Trainer",
Description: "Read-only with write access to exercise and nutrition", Description: "Exercise, nutrition, vitals",
Grants: []RoleGrant{ Grants: []RoleGrant{
{Category: 0, Ops: "r"}, // Read everything {Category: CategoryVital, Ops: "r"},
{Category: CategoryExercise, Ops: "rw"}, // Write exercise {Category: CategoryExercise, Ops: "rw"},
{Category: CategoryNutrition, Ops: "rw"}, // Write nutrition {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", Name: "Friend",
Description: "Read-only access", Description: "View only",
Grants: []RoleGrant{ Grants: []RoleGrant{
{Category: 0, Ops: "r"}, // Read everything {Category: 0, Ops: "r"},
}, },
}, },
{ {
Name: "Researcher", Name: "Researcher",
Description: "Read-only access for research purposes", Description: "View only for research",
Grants: []RoleGrant{ 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 return nil
} }
// findOrCreateCategoryRoot finds or creates a root entry for category-level grants // findOrCreateCategoryRoot is an alias for EnsureCategoryRoot (in access.go)
// This is a virtual entry that serves as parent for all entries of that category
func findOrCreateCategoryRoot(dossierID string, category int) (string, error) { func findOrCreateCategoryRoot(dossierID string, category int) (string, error) {
// Look for existing category root entry (type = "category_root", use empty string for system context) return EnsureCategoryRoot(dossierID, category)
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
} }
// RevokeRole removes all grants with the specified role for a grantee on a dossier // RevokeRole removes all grants with the specified role for a grantee on a dossier

View File

@ -328,6 +328,7 @@ type Entry struct {
Status int `db:"status"` Status int `db:"status"`
Tags string `db:"tags"` Tags string `db:"tags"`
Data string `db:"data"` Data string `db:"data"`
SearchKey string `db:"search_key"` // LOINC (labs), gene (genome), modality (imaging) - encrypted
} }
// Audit represents an audit log entry // Audit represents an audit log entry

View File

@ -43,6 +43,7 @@ type EntryFilter struct {
DossierID string DossierID string
Type string Type string
Value string Value string
SearchKey string
FromDate int64 FromDate int64
ToDate int64 ToDate int64
Limit int Limit int
@ -60,7 +61,7 @@ func EntryWrite(ctx *AccessContext, entries ...*Entry) error {
return fmt.Errorf("entry missing dossier_id") return fmt.Errorf("entry missing dossier_id")
} }
// Check write on parent (or root if no parent) // 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 return err
} }
} }
@ -84,7 +85,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error {
if err != nil { if err != nil {
continue // Entry doesn't exist, skip 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 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. // EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root.
func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error { func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root // 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 err
} }
@ -118,7 +119,7 @@ func EntryGet(ctx *AccessContext, id string) (*Entry, error) {
} }
// RBAC: Check read permission // 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 return nil, err
} }
@ -147,7 +148,7 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
} }
} }
if dossierID != "" { if dossierID != "" {
if err := checkAccess(accessorID, dossierID, parent, 'r'); err != nil { if err := checkAccess(accessorID, dossierID, parent, category, 'r'); err != nil {
return nil, err return nil, err
} }
} }
@ -180,6 +181,10 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
q += " AND value = ?" q += " AND value = ?"
args = append(args, CryptoEncrypt(f.Value)) args = append(args, CryptoEncrypt(f.Value))
} }
if f.SearchKey != "" {
q += " AND search_key = ?"
args = append(args, CryptoEncrypt(f.SearchKey))
}
if f.FromDate > 0 { if f.FromDate > 0 {
q += " AND timestamp >= ?" q += " AND timestamp >= ?"
args = append(args, f.FromDate) args = append(args, f.FromDate)
@ -219,7 +224,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
for _, d := range dossiers { for _, d := range dossiers {
if d.DossierID != "" { if d.DossierID != "" {
// Update - need manage permission (unless creating own or system) // 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 return err
} }
} }
@ -245,7 +250,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
func DossierRemove(ctx *AccessContext, ids ...string) error { func DossierRemove(ctx *AccessContext, ids ...string) error {
// RBAC: Check manage permission for each dossier // RBAC: Check manage permission for each dossier
for _, id := range ids { 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 return err
} }
} }
@ -255,7 +260,7 @@ func DossierRemove(ctx *AccessContext, ids ...string) error {
// DossierGet retrieves a dossier. Requires read permission. // DossierGet retrieves a dossier. Requires read permission.
func DossierGet(ctx *AccessContext, id string) (*Dossier, error) { func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
// RBAC: Check read permission // 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 return nil, err
} }
@ -552,7 +557,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
} }
// RBAC: Check read permission // 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 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. // ObjectWrite encrypts and writes data to the object store. Requires write permission.
func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error { func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error {
// RBAC: Check write permission // 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 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. // ObjectRead reads and decrypts data from the object store. Requires read permission.
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) { func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
// RBAC: Check read permission // 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 return nil, err
} }
@ -675,7 +680,7 @@ func objectReadRaw(dossierID, entryID string) ([]byte, error) {
// ObjectRemove deletes an object from the store. Requires delete permission. // ObjectRemove deletes an object from the store. Requires delete permission.
func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error { func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
// RBAC: Check delete permission // 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 err
} }
return os.Remove(ObjectPath(dossierID, entryID)) 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. // ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission.
func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error { func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root // 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 err
} }
return os.RemoveAll(filepath.Join(ObjectDir, dossierID)) return os.RemoveAll(filepath.Join(ObjectDir, dossierID))
@ -841,6 +846,48 @@ func MigrateOldAccess() int {
return migrated 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 // AccessGrantGet retrieves a single access grant by ID
func AccessGrantGet(id string) (*Access, error) { func AccessGrantGet(id string) (*Access, error) {
a := &Access{} a := &Access{}

View File

@ -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
-- ============================================================================

View File

@ -57,6 +57,7 @@ var validPaths = []string{
"/privacy-policy", "/privacy-policy",
"/security", "/security",
"/legal/dpa", "/legal/dpa",
"/legal/terms",
"/demo", "/demo",
"/oauth/authorize", "/oauth/authorize",
"/oauth/token", "/oauth/token",

View File

@ -131,6 +131,7 @@ type PageData struct {
EntryGrants []EntryGrant EntryGrants []EntryGrant
// RBAC edit page // RBAC edit page
CategoriesRBAC []CategoryRBACView CategoriesRBAC []CategoryRBACView
SelectedRole string
// Dossier: unified sections // Dossier: unified sections
Sections []DossierSection Sections []DossierSection
LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh} LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh}
@ -738,6 +739,11 @@ func handleDPA(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r) p := getLoggedInDossier(r)
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p}) 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) { func handleStyleguide(w http.ResponseWriter, r *http.Request) {
p := getLoggedInDossier(r) p := getLoggedInDossier(r)
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p}) render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
@ -1437,7 +1443,6 @@ type RoleView struct {
type CategoryRBACView struct { type CategoryRBACView struct {
ID int ID int
Name string Name string
Description string
CanRead bool CanRead bool
CanWrite bool CanWrite bool
CanDelete bool CanDelete bool
@ -1643,35 +1648,18 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
} }
if action == "update" { if action == "update" {
// Build base ops from checkboxes roleName := r.FormValue("role")
baseOps := "" if roleName == "" { roleName = "Custom" }
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" }
// Clear existing grants // Clear existing grants
lib.AccessRevokeAll(targetID, granteeID) lib.AccessRevokeAll(targetID, granteeID)
// Create root grant if base ops specified // Create per-category grants (all categories except All=0 and Upload=5)
if baseOps != "" { for _, cat := range lib.Categories() {
lib.AccessGrantWrite(&lib.Access{ if cat.ID == lib.CategoryUpload {
DossierID: targetID, continue
GranteeID: granteeID,
EntryID: "",
Role: "Custom",
Ops: baseOps,
})
} }
catID := cat.ID
// 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 {
catOps := "" catOps := ""
if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" } if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" }
if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" } 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 r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" }
if catOps != "" { if catOps != "" {
// Ensure category entry exists entryID, err := lib.EnsureCategoryRoot(targetID, catID)
entryID, err := lib.EnsureCategoryEntry(targetID, catID)
if err == nil { if err == nil {
lib.AccessGrantWrite(&lib.Access{ lib.AccessGrantWrite(&lib.Access{
DossierID: targetID, DossierID: targetID,
GranteeID: granteeID, GranteeID: granteeID,
EntryID: entryID, EntryID: entryID,
Role: "Custom", Role: roleName,
Ops: catOps, Ops: catOps,
}) })
} }
@ -1694,7 +1681,7 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
} }
lib.InvalidateCacheForAccessor(granteeID) 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) http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
return return
} }
@ -1703,25 +1690,20 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
// GET: Load current grants and build view // GET: Load current grants and build view
grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID}) grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID})
// Parse grants to determine permissions // Parse grants to determine per-category permissions and detect role
hasRead, hasWrite, hasDelete, hasManage := false, false, false, false
catPerms := make(map[int]map[rune]bool) // catID -> op -> bool catPerms := make(map[int]map[rune]bool) // catID -> op -> bool
selectedRole := "Custom"
for _, g := range grants { for _, g := range grants {
if g.Role != "" && selectedRole == "Custom" {
selectedRole = g.Role
} else if g.Role != "" && g.Role != selectedRole {
selectedRole = "Custom"
}
if g.EntryID == "" { if g.EntryID == "" {
// Root grant - applies to base permissions continue // Root grants not shown in per-category view
for _, op := range g.Ops {
switch op {
case 'r': hasRead = true
case 'w': hasWrite = true
case 'd': hasDelete = true
case 'm': hasManage = true
} }
}
} else {
// Entry-specific grant - find which category
entry, err := lib.EntryGet(nil, g.EntryID) entry, err := lib.EntryGet(nil, g.EntryID)
if err == nil && entry != nil && entry.Type == "category" { if err == nil && entry != nil && (entry.Type == "category" || entry.Type == "category_root") {
if catPerms[entry.Category] == nil { if catPerms[entry.Category] == nil {
catPerms[entry.Category] = make(map[rune]bool) catPerms[entry.Category] = make(map[rune]bool)
} }
@ -1730,32 +1712,17 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
}
// 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 var categoriesRBAC []CategoryRBACView
for _, def := range categoryDefs { for _, cat := range lib.Categories() {
perms := catPerms[def.ID] if cat.ID == lib.CategoryUpload {
continue
}
perms := catPerms[cat.ID]
categoriesRBAC = append(categoriesRBAC, CategoryRBACView{ categoriesRBAC = append(categoriesRBAC, CategoryRBACView{
ID: def.ID, ID: cat.ID,
Name: def.Name, Name: cat.Name,
Description: def.Desc,
CanRead: perms['r'], CanRead: perms['r'],
CanWrite: perms['w'], CanWrite: perms['w'],
CanDelete: perms['d'], CanDelete: perms['d'],
@ -1787,12 +1754,9 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
TargetDossier: target, TargetDossier: target,
GranteeID: granteeID, GranteeID: granteeID,
GranteeName: grantee.Name, GranteeName: grantee.Name,
HasRead: hasRead,
HasWrite: hasWrite,
HasDelete: hasDelete,
HasManage: hasManage,
CategoriesRBAC: categoriesRBAC, CategoriesRBAC: categoriesRBAC,
Roles: roles, Roles: roles,
SelectedRole: selectedRole,
Success: successMsg, Success: successMsg,
} }
@ -2026,6 +1990,8 @@ func setupMux() http.Handler {
mux.HandleFunc("/security", handleSecurity) mux.HandleFunc("/security", handleSecurity)
mux.HandleFunc("/legal/dpa", handleDPA) 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/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("/styleguide", handleStyleguide)
mux.HandleFunc("/demo", handleDemo) mux.HandleFunc("/demo", handleDemo)
mux.HandleFunc("/dossier/add", handleAddDossier) mux.HandleFunc("/dossier/add", handleAddDossier)
@ -2103,6 +2069,10 @@ func main() {
if n := lib.MigrateOldAccess(); n > 0 { if n := lib.MigrateOldAccess(); n > 0 {
fmt.Printf("Migrated %d access grants from dossier_access\n", n) 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() loadTranslations()
lib.TranslateInit("lang") // also init lib translations for CategoryTranslate lib.TranslateInit("lang") // also init lib translations for CategoryTranslate

View File

@ -378,13 +378,14 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
}, },
{ {
"name": "query_entries", "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{}{ "inputSchema": map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"}, "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)"}, "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)"}, "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"}, "parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"},
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"}, "from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"}, "to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
@ -526,11 +527,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
} }
category, _ := params.Arguments["category"].(string) category, _ := params.Arguments["category"].(string)
typ, _ := params.Arguments["type"].(string) typ, _ := params.Arguments["type"].(string)
searchKey, _ := params.Arguments["search_key"].(string)
parent, _ := params.Arguments["parent"].(string) parent, _ := params.Arguments["parent"].(string)
from, _ := params.Arguments["from"].(string) from, _ := params.Arguments["from"].(string)
to, _ := params.Arguments["to"].(string) to, _ := params.Arguments["to"].(string)
limit, _ := params.Arguments["limit"].(float64) 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 { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return

View File

@ -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 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{} params := map[string]string{}
if category != "" { if category != "" {
params["category"] = category params["category"] = category
@ -151,6 +151,9 @@ func mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to strin
if typ != "" { if typ != "" {
params["type"] = typ params["type"] = typ
} }
if searchKey != "" {
params["search_key"] = searchKey
}
if parent != "" { if parent != "" {
params["parent"] = parent params["parent"] = parent
} }

View File

@ -121,12 +121,26 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
} }
json.NewDecoder(resp.Body).Decode(&apiPrompts) 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 // Convert to view models
var prompts []PromptView var prompts []PromptView
for _, ap := range apiPrompts { for _, ap := range apiPrompts {
pv := PromptView{ pv := PromptView{
ID: ap.ID, ID: ap.ID,
Category: ap.Category, Category: translateCategory(ap.Category),
Type: ap.Type, Type: ap.Type,
Question: ap.Question, Question: ap.Question,
NextAsk: ap.NextAsk, NextAsk: ap.NextAsk,

View File

@ -28,6 +28,7 @@
<a href="/privacy-policy">Privacy Policy</a> <a href="/privacy-policy">Privacy Policy</a>
<a href="/security">Security</a> <a href="/security">Security</a>
<a href="/legal/dpa">DPA</a> <a href="/legal/dpa">DPA</a>
<a href="/legal/terms">Terms</a>
</div> </div>
</div> </div>
{{if .Dossier}}<a href="/connect" class="nav-link">Connect</a>{{end}} {{if .Dossier}}<a href="/connect" class="nav-link">Connect</a>{{end}}
@ -104,6 +105,7 @@
{{else if eq .Page "privacy"}}{{template "privacy" .}} {{else if eq .Page "privacy"}}{{template "privacy" .}}
{{else if eq .Page "security"}}{{template "security" .}} {{else if eq .Page "security"}}{{template "security" .}}
{{else if eq .Page "dpa"}}{{template "dpa" .}} {{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 "styleguide"}}{{template "styleguide" .}}
{{else if eq .Page "pricing"}}{{template "pricing" .}} {{else if eq .Page "pricing"}}{{template "pricing" .}}
{{else if eq .Page "faq"}}{{template "faq" .}} {{else if eq .Page "faq"}}{{template "faq" .}}

View File

@ -316,20 +316,31 @@ function renderFilterChart(card, table, q) {
return; 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.style.display = '';
wrapper.classList.remove('collapsed'); wrapper.classList.remove('collapsed');
let html = ''; let html = '';
for (const [loinc, s] of chartable) { 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 // Build display name: "Full Name (Abbr)" or fallback to abbreviation
const fullName = loincNames[loinc] || s.abbr; const fullName = loincNames[loinc] || s.abbr;
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : 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; 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 W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 };
const pw = W - PAD.left - PAD.right; const pw = W - PAD.left - PAD.right;
const ph = H - PAD.top - PAD.bottom; 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 // Never show negative Y when all values are >= 0
if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin); 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 tRange = tMax - tMin || 1;
const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw; const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw;

View File

@ -127,7 +127,7 @@
<h2>Data we process</h2> <h2>Data we process</h2>
<h3>Health data.</h3> <h3>Health data.</h3>
<p>Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.</p> <p>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.</p>
<h3>Account data.</h3> <h3>Account data.</h3>
<p>Name, email address, date of birth, and sex. Used for account management and medical context.</p> <p>Name, email address, date of birth, and sex. Used for account management and medical context.</p>
@ -238,8 +238,9 @@
<div class="dpa-card"> <div class="dpa-card">
<h2>Contact</h2> <h2>Contact</h2>
<p>Data Protection Officer: Johan Jongsma</p>
<p>Questions about data processing: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p> <p>Questions about data processing: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
<p>This agreement was last updated on January 21, 2026.</p> <p>This agreement was last updated on February 8, 2026.</p>
</div> </div>
{{template "footer"}} {{template "footer"}}

View File

@ -1,7 +1,7 @@
{{define "edit_rbac"}} {{define "edit_rbac"}}
<div class="sg-container" style="justify-content: center;"> <div class="sg-container" style="justify-content: center;">
<div style="flex: 1; display: flex; align-items: flex-start; padding-top: 5vh; justify-content: center;"> <div style="flex: 1; display: flex; align-items: flex-start; padding-top: 16px; justify-content: center;">
<div class="data-card" style="padding: 48px; max-width: 800px; width: 100%;"> <div class="data-card" style="padding: 48px; max-width: 780px; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;"> <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div> <div>
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">Edit permissions</h1> <h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">Edit permissions</h1>
@ -15,95 +15,54 @@
<form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST"> <form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST">
<input type="hidden" name="action" value="update"> <input type="hidden" name="action" value="update">
<input type="hidden" name="role" id="roleHidden" value="{{.SelectedRole}}">
<!-- Role Selector --> <!-- Role Selector -->
<div style="margin-bottom: 32px;"> <div style="margin-bottom: 24px;">
<div class="form-group"> <label style="font-weight: 600; margin-bottom: 8px; display: block;">Role</label>
<label style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Role Template</label>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 12px;">Quick presets for common access patterns</p>
<select id="roleSelect" class="sg-select" style="width: 100%;"> <select id="roleSelect" class="sg-select" style="width: 100%;">
<option value="">Custom (manual selection)</option> <option value="Custom"{{if eq .SelectedRole "Custom"}} selected{{end}}>Custom</option>
{{range .Roles}} {{range .Roles}}
<option value="{{.Name}}" data-grants='{{.GrantsJSON}}'>{{.Name}} — {{.Description}}</option> <option value="{{.Name}}" data-grants='{{.GrantsJSON}}'{{if eq $.SelectedRole .Name}} selected{{end}}>{{.Name}} &mdash; {{.Description}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
</div>
<!-- Base Operations --> <!-- Category Permission Table -->
<div style="margin-bottom: 32px; border-top: 1px solid var(--border-light); padding-top: 32px;"> <table style="width: 100%; border-collapse: collapse; font-size: 0.9rem; table-layout: fixed;">
<h3 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Base Permissions</h3> <colgroup>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Operations that apply across all data</p> <col style="width: auto;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;"> <col style="width: 72px;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;"> <col style="width: 72px;">
<input type="checkbox" id="op_r" name="op_r" value="1" {{if .HasRead}}checked{{end}}> <col style="width: 72px;">
<div style="flex: 1;"> <col style="width: 72px;">
<span style="font-weight: 500;">Read</span> <col style="width: 72px;">
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">View all data</span> </colgroup>
</div> <thead>
</label> <tr style="border-bottom: 2px solid var(--border-light);">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;"> <th style="text-align: left; padding: 8px 12px; font-weight: 600;">Category</th>
<input type="checkbox" id="op_w" name="op_w" value="1" {{if .HasWrite}}checked{{end}}> <th style="text-align: center; padding: 8px 4px; font-weight: 600;">All</th>
<div style="flex: 1;"> <th style="text-align: center; padding: 8px 4px; font-weight: 600;"><label class="col-toggle" data-op="r" title="Toggle all Read"><input type="checkbox" class="col-cb" data-op="r"> Read</label></th>
<span style="font-weight: 500;">Write</span> <th style="text-align: center; padding: 8px 4px; font-weight: 600;"><label class="col-toggle" data-op="w" title="Toggle all Write"><input type="checkbox" class="col-cb" data-op="w"> Write</label></th>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Add & update data</span> <th style="text-align: center; padding: 8px 4px; font-weight: 600;"><label class="col-toggle" data-op="d" title="Toggle all Delete"><input type="checkbox" class="col-cb" data-op="d"> Delete</label></th>
</div> <th style="text-align: center; padding: 8px 4px; font-weight: 600;"><label class="col-toggle" data-op="m" title="Toggle all Manage"><input type="checkbox" class="col-cb" data-op="m"> Manage</label></th>
</label> </tr>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;"> </thead>
<input type="checkbox" id="op_d" name="op_d" value="1" {{if .HasDelete}}checked{{end}}> <tbody>
<div style="flex: 1;"> {{range .CategoriesRBAC}}
<span style="font-weight: 500;">Delete</span> <tr style="border-bottom: 1px solid var(--border-light);" data-cat="{{.ID}}">
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Remove data</span> <td style="padding: 6px 12px; text-transform: capitalize;">{{.Name}}</td>
</div> <td style="text-align: center; padding: 6px 4px;"><input type="checkbox" class="row-toggle"></td>
</label> <td style="text-align: center; padding: 6px 4px;"><input type="checkbox" name="cat_{{.ID}}_r" value="1" class="perm-cb op-r" {{if .CanRead}}checked{{end}}></td>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;"> <td style="text-align: center; padding: 6px 4px;"><input type="checkbox" name="cat_{{.ID}}_w" value="1" class="perm-cb op-w" {{if .CanWrite}}checked{{end}}></td>
<input type="checkbox" id="op_m" name="op_m" value="1" {{if .HasManage}}checked{{end}}> <td style="text-align: center; padding: 6px 4px;"><input type="checkbox" name="cat_{{.ID}}_d" value="1" class="perm-cb op-d" {{if .CanDelete}}checked{{end}}></td>
<div style="flex: 1;"> <td style="text-align: center; padding: 6px 4px;"><input type="checkbox" name="cat_{{.ID}}_m" value="1" class="perm-cb op-m" {{if .CanManage}}checked{{end}}></td>
<span style="font-weight: 500;">Manage</span> </tr>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Grant access to others</span>
</div>
</label>
</div>
</div>
<!-- Category-Specific Access -->
<div style="margin-bottom: 32px; border-top: 1px solid var(--border-light); padding-top: 32px;">
<h3 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Category Permissions</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Fine-grained control per data type</p>
<div style="display: flex; flex-direction: column; gap: 16px;">
{{range .Categories}}
<div style="background: var(--bg-muted); border-radius: 8px; padding: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<span style="font-weight: 600; font-size: 0.95rem;">{{.Name}}</span>
<span style="display: block; color: var(--text-muted); font-size: 0.8rem; margin-top: 2px;">{{.Description}}</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_r" value="1" class="cat-{{.ID}}-perm" {{if .CanRead}}checked{{end}}>
<span>Read</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_w" value="1" class="cat-{{.ID}}-perm" {{if .CanWrite}}checked{{end}}>
<span>Write</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_d" value="1" class="cat-{{.ID}}-perm" {{if .CanDelete}}checked{{end}}>
<span>Delete</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_m" value="1" class="cat-{{.ID}}-perm" {{if .CanManage}}checked{{end}}>
<span>Manage</span>
</label>
</div>
</div>
{{end}} {{end}}
</div> </tbody>
</div> </table>
<div style="display: flex; gap: 12px; margin-top: 24px;"> <div style="display: flex; gap: 12px; margin-top: 32px;">
<a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary" style="flex: 1; text-align: center;">Cancel</a> <a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary" style="flex: 1; text-align: center;">Cancel</a>
<button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button> <button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button>
</div> </div>
@ -120,49 +79,106 @@
</div> </div>
</div> </div>
<style>
.col-toggle { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
</style>
<script> <script>
document.getElementById('roleSelect').addEventListener('change', function() { (function() {
if (!this.value) return; const roleSelect = document.getElementById('roleSelect');
const roleHidden = document.getElementById('roleHidden');
const allCbs = document.querySelectorAll('.perm-cb');
const grantsJSON = this.options[this.selectedOptions[0]].dataset.grants; const catIDs = [];
document.querySelectorAll('tr[data-cat]').forEach(tr => {
catIDs.push(parseInt(tr.dataset.cat));
});
// --- Role selection ---
roleSelect.addEventListener('change', function() {
roleHidden.value = this.value;
if (this.value === 'Custom') return;
const opt = this.options[this.selectedIndex];
const grantsJSON = opt.dataset.grants;
if (!grantsJSON) return; if (!grantsJSON) return;
const grants = JSON.parse(grantsJSON); const grants = JSON.parse(grantsJSON);
// Clear all checkboxes first allCbs.forEach(cb => cb.checked = false);
document.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
// Apply grants let rootOps = '';
grants.forEach(grant => { grants.forEach(g => { if (g.Category === 0) rootOps = g.Ops || ''; });
const ops = grant.Ops || ''; if (rootOps) catIDs.forEach(id => setOps(id, rootOps));
grants.forEach(g => { if (g.Category > 0) setOps(g.Category, g.Ops || ''); });
syncAll();
});
if (grant.Category === 0) { function setOps(catID, ops) {
// Root level - apply to base ops const row = document.querySelector('tr[data-cat="' + catID + '"]');
if (ops.includes('r')) document.getElementById('op_r').checked = true; if (!row) return;
if (ops.includes('w')) document.getElementById('op_w').checked = true; const cbs = row.querySelectorAll('.perm-cb');
if (ops.includes('d')) document.getElementById('op_d').checked = true; if (cbs[0]) cbs[0].checked = ops.includes('r');
if (ops.includes('m')) document.getElementById('op_m').checked = true; if (cbs[1]) cbs[1].checked = ops.includes('w');
} else { if (cbs[2]) cbs[2].checked = ops.includes('d');
// Category specific if (cbs[3]) cbs[3].checked = ops.includes('m');
const catID = grant.Category;
if (ops.includes('r')) {
const cb = document.querySelector(`input[name="cat_${catID}_r"]`);
if (cb) cb.checked = true;
}
if (ops.includes('w')) {
const cb = document.querySelector(`input[name="cat_${catID}_w"]`);
if (cb) cb.checked = true;
}
if (ops.includes('d')) {
const cb = document.querySelector(`input[name="cat_${catID}_d"]`);
if (cb) cb.checked = true;
}
if (ops.includes('m')) {
const cb = document.querySelector(`input[name="cat_${catID}_m"]`);
if (cb) cb.checked = true;
}
} }
// --- Manual perm change → Custom ---
allCbs.forEach(cb => {
cb.addEventListener('change', function() {
roleSelect.value = 'Custom';
roleHidden.value = 'Custom';
syncRowToggle(this.closest('tr'));
syncColToggles();
}); });
}); });
// --- Row toggle ---
document.querySelectorAll('.row-toggle').forEach(toggle => {
toggle.addEventListener('change', function() {
const row = this.closest('tr');
row.querySelectorAll('.perm-cb').forEach(cb => cb.checked = this.checked);
roleSelect.value = 'Custom';
roleHidden.value = 'Custom';
syncColToggles();
});
});
// --- Column toggle (checkbox in header) ---
document.querySelectorAll('.col-cb').forEach(cb => {
cb.addEventListener('change', function() {
const op = this.dataset.op;
document.querySelectorAll('.op-' + op).forEach(c => c.checked = this.checked);
roleSelect.value = 'Custom';
roleHidden.value = 'Custom';
syncRowToggles();
});
});
// --- Sync helpers ---
function syncRowToggle(row) {
const cbs = row.querySelectorAll('.perm-cb');
const toggle = row.querySelector('.row-toggle');
if (toggle) toggle.checked = cbs.length > 0 && Array.from(cbs).every(cb => cb.checked);
}
function syncRowToggles() {
document.querySelectorAll('tr[data-cat]').forEach(syncRowToggle);
}
function syncColToggles() {
document.querySelectorAll('.col-cb').forEach(cb => {
const colCbs = document.querySelectorAll('.op-' + cb.dataset.op);
cb.checked = colCbs.length > 0 && Array.from(colCbs).every(c => c.checked);
});
}
function syncAll() {
syncRowToggles();
syncColToggles();
}
// --- Init ---
syncAll();
})();
</script> </script>
{{end}} {{end}}

View File

@ -3,6 +3,7 @@
<div class="sg-footer-left"> <div class="sg-footer-left">
<span>© 2026</span> <span>© 2026</span>
<a href="/privacy-policy">Privacy</a> <a href="/privacy-policy">Privacy</a>
<a href="/legal/terms">Terms</a>
<a href="/pricing">Pricing</a> <a href="/pricing">Pricing</a>
</div> </div>
<span class="sg-footer-right"><span class="inou">inou</span> <span class="health">health</span></span> <span class="sg-footer-right"><span class="inou">inou</span> <span class="health">health</span></span>

View File

@ -147,6 +147,12 @@
<p>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.</p> <p>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.</p>
</div> </div>
<div class="privacy-card">
<h2>Legal basis for processing</h2>
<p>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.</p>
<p>Genetic and genomic data is classified as special category data under GDPR Article 9. By uploading genetic data to <span class="inou-brand">inou</span>, 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.</p>
</div>
<div class="privacy-card"> <div class="privacy-card">
<h2>What we promise</h2> <h2>What we promise</h2>
@ -192,7 +198,7 @@
<p>Found a mistake? You can correct it yourself, or ask us to help.</p> <p>Found a mistake? You can correct it yourself, or ask us to help.</p>
<h3>Delete everything.</h3> <h3>Delete everything.</h3>
<p>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.</p> <p>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.</p>
<h3>Take it with you.</h3> <h3>Take it with you.</h3>
<p>Want to move to another service? We'll export your data in standard formats. You're never locked in.</p> <p>Want to move to another service? We'll export your data in standard formats. You're never locked in.</p>
@ -213,6 +219,11 @@
<p>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.</p> <p>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.</p>
</div> </div>
<div class="privacy-card">
<h2>Not a medical device</h2>
<p><span class="inou-brand">inou</span> 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.</p>
</div>
<div class="privacy-card"> <div class="privacy-card">
<h2>Children's privacy</h2> <h2>Children's privacy</h2>
<p><span class="inou-brand">inou</span> 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.</p> <p><span class="inou-brand">inou</span> 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.</p>
@ -223,7 +234,8 @@
<p>We comply with <strong>FADP</strong> (Swiss data protection), <strong>GDPR</strong> (European data protection), and <strong>HIPAA</strong> (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.</p> <p>We comply with <strong>FADP</strong> (Swiss data protection), <strong>GDPR</strong> (European data protection), and <strong>HIPAA</strong> (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.</p>
<p>We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p> <p>We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p>
<p>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.</p> <p>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.</p>
<p>Questions, concerns, or requests: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p> <p>Our Data Protection Officer is Johan Jongsma. For all privacy and data protection inquiries, contact <a href="mailto:privacy@inou.com">privacy@inou.com</a>.</p>
<p>This policy was last updated on February 8, 2026.</p>
</div> </div>
{{template "footer"}} {{template "footer"}}

View File

@ -1,12 +1,17 @@
{{define "prompts"}} {{define "prompts"}}
<div class="sg-container"> <div class="sg-container">
<h1 style="font-size: 2.5rem; font-weight: 700;">{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in</h1> <h1 style="font-size: 2.5rem; font-weight: 700;">{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in</h1>
<p class="intro" style="font-size: 1.15rem; font-weight: 300; line-height: 1.8;">Track daily measurements and observations</p> <p class="intro" style="font-size: 1.15rem; font-weight: 300; line-height: 1.8; margin-bottom: 8px;">Track daily measurements and observations</p>
<div class="help-banner" style="background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px 16px; border-radius: 6px; margin-bottom: 24px; font-size: 0.9rem; line-height: 1.6;">
<strong>💡 How it works:</strong> 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.
</div>
{{if .Error}}<div class="msg msg-error">{{.Error}}</div>{{end}} {{if .Error}}<div class="msg msg-error">{{.Error}}</div>{{end}}
{{if .Success}}<div class="msg msg-success">{{.Success}}</div>{{end}} {{if .Success}}<div class="msg msg-success">{{.Success}}</div>{{end}}
{{if .Prompts}} {{if or .DuePrompts .UpcomingPrompts .Entries}}
<div class="data-card"> <div class="data-card">
<!-- Section header --> <!-- Section header -->
<div class="prompt-section-header"> <div class="prompt-section-header">
@ -18,10 +23,86 @@
</div> </div>
<div class="prompt-list"> <div class="prompt-list">
{{/* 1. FREEFORM CARD - Always visible */}}
{{range .DuePrompts}}
{{if .IsFreeform}}
<div class="prompt-item prompt-freeform" data-prompt-id="{{.ID}}">
<form class="prompt-form" data-prompt-id="{{.ID}}">
<div class="prompt-header">
<span class="prompt-question">{{.Question}}</span>
<span class="prompt-due">optional</span>
</div>
<div class="prompt-body">
<div style="display: flex; gap: 12px; align-items: flex-end;">
<textarea name="response_raw" class="prompt-textarea" rows="3" style="flex: 1;"
placeholder="Type what you want to track... (e.g., 'I take vitamin D every morning' or 'walked 30 minutes today')"
onkeydown="if(event.key==='Enter' && (event.metaKey || event.ctrlKey)){event.preventDefault();saveItem(this.closest('.prompt-item'));}"></textarea>
<button type="button" class="btn btn-primary" onclick="saveItem(this.closest('.prompt-item'))" style="align-self: flex-end;">Save</button>
</div>
<div class="prompt-hint" style="margin-top: 8px; text-align: right;">
<span id="kbd-hint">or press Ctrl+Enter</span>
</div>
</div>
</form>
</div>
{{end}}
{{end}}
{{/* 2. PENDING CARDS - Due but not filled yet */}}
{{range .DuePrompts}}
{{if not .IsFreeform}}
{{if not .HasResponse}}
<div class="prompt-item prompt-pending" data-prompt-id="{{.ID}}">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm">
<span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div>
<form class="prompt-form" data-prompt-id="{{.ID}}">
<div class="prompt-header">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-question">{{.Question}}</span>
<span class="prompt-due">{{.NextAskFormatted}}</span>
</div>
<div class="prompt-body">
{{if .Fields}}
{{if eq (len .Fields) 1}}
{{with index .Fields 0}}
{{if eq .Type "number"}}
<div class="prompt-input-row">
<input type="number" name="field_{{.Key}}"
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
class="prompt-input-number"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>
{{else if eq .Type "checkbox"}}
<label class="prompt-checkbox">
<input type="checkbox" name="field_{{.Key}}" value="1">
<span class="prompt-checkbox-box"></span>
<span class="prompt-checkbox-label">{{if .Label}}{{.Label}}{{else}}Yes{{end}}</span>
</label>
{{end}}
{{end}}
{{end}}
{{end}}
</div>
</form>
</div>
{{end}}
{{end}}
{{end}}
{{/* 3. FILLED CARDS - Entries from today */}}
{{range .Entries}} {{range .Entries}}
<div class="prompt-item entry-item" data-entry-id="{{.ID}}"> <div class="prompt-item prompt-filled" data-entry-id="{{.ID}}">
<a href="#" class="prompt-dismiss" onclick="deleteEntry('{{.ID}}'); return false;" title="Delete">✕</a> <a href="#" class="prompt-dismiss" onclick="deleteEntry('{{.ID}}'); return false;" title="Delete">✕</a>
<div class="prompt-header"> <div class="prompt-header">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-question">{{.Question}}</span> <span class="prompt-question">{{.Question}}</span>
<span class="prompt-saved-time">{{.TimeFormatted}}</span> <span class="prompt-saved-time">{{.TimeFormatted}}</span>
</div> </div>
@ -43,136 +124,78 @@
<div class="prompt-saved-footer"> <div class="prompt-saved-footer">
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a> <a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
</div> </div>
{{if .SourceInput}}<div class="prompt-source"> "{{.SourceInput}}"</div>{{end}} {{if .SourceInput}}<div class="prompt-source">Created from: "{{.SourceInput}}"</div>{{end}}
</div> </div>
{{end}} {{end}}
{{range .Prompts}} </div>
</div>
{{if .UpcomingPrompts}}
<div class="data-card" style="margin-top: 24px;">
<div class="prompt-section-header">
<div class="prompt-section-bar" style="background: #94a3b8;"></div>
<div class="prompt-section-info">
<div class="prompt-section-title">UPCOMING</div>
<div class="prompt-section-subtitle">{{len .UpcomingPrompts}} scheduled</div>
</div>
</div>
<div class="prompt-list">
{{range .UpcomingPrompts}}
{{$prompt := .}} {{$prompt := .}}
<div class="prompt-item{{if not .IsDue}} prompt-item-future{{end}}" data-prompt-id="{{.ID}}"> <div class="prompt-item prompt-item-future" data-prompt-id="{{.ID}}">
<!-- Dismiss button -->
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a> <a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm"> <div class="dismiss-confirm">
<span>Stop tracking?</span> <span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a> <a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a> <a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div> </div>
<!-- Saved state (only show if due AND has response) -->
<div class="prompt-saved" style="display: {{if and .HasResponse .IsDue}}block{{else}}none{{end}};">
<div class="prompt-saved-header">
<span class="prompt-question">{{.Question}}</span>
<span class="prompt-saved-value">{{.LastResponseRaw}}</span>
</div>
<div class="prompt-saved-footer">
<span class="prompt-saved-time">{{if .LastResponseFormatted}}{{.LastResponseFormatted}}{{else}}just now{{end}}</span>
<a href="#" class="prompt-edit" onclick="editPrompt(this); return false;">edit</a>
</div>
{{if .SourceInput}}<div class="prompt-source">↳ "{{.SourceInput}}"</div>{{end}}
</div>
<!-- Input state (show if not due, OR if due but no response) -->
<form class="prompt-form" data-prompt-id="{{.ID}}"{{if and .HasResponse .IsDue}} style="display: none;"{{end}}>
<div class="prompt-header"> <div class="prompt-header">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-question">{{.Question}}</span> <span class="prompt-question">{{.Question}}</span>
{{if .IsFreeform}}<span class="prompt-due">optional</span>{{else if .NextAsk}}<span class="prompt-due{{if .IsOverdue}} prompt-overdue{{end}}">{{.NextAskFormatted}}</span>{{end}} <span class="prompt-due">{{.NextAskFormatted}}</span>
</div> </div>
{{/* Show preview of what the input will look like */}}
<div class="prompt-body">
{{if .Fields}} {{if .Fields}}
<div class="prompt-body prompt-preview">
{{if eq (len .Fields) 1}} {{if eq (len .Fields) 1}}
{{with index .Fields 0}} {{with index .Fields 0}}
{{if eq .Type "number"}} {{if eq .Type "number"}}
<div class="prompt-input-row"> <div class="prompt-input-row">
<input type="number" name="field_{{.Key}}" <input type="number" disabled placeholder="Amount" class="prompt-input-number">
{{if .Min}}min="{{.Min}}"{{end}}
{{if .Max}}max="{{.Max}}"{{end}}
{{if .Step}}step="{{.Step}}"{{end}}
{{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}} {{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div> </div>
{{else if eq .Type "checkbox"}} {{else if eq .Type "checkbox"}}
<label class="prompt-checkbox"> <label class="prompt-checkbox">
<input type="checkbox" name="field_{{.Key}}" value="1" {{if .Value}}checked{{end}}> <input type="checkbox" disabled>
<span class="prompt-checkbox-box"></span> <span class="prompt-checkbox-box"></span>
<span class="prompt-checkbox-label">{{if .Label}}{{.Label}}{{else}}Yes{{end}}</span> <span class="prompt-checkbox-label">Yes</span>
</label> </label>
{{else if eq .Type "scale"}}
<div class="prompt-scale">
{{if .Label}}<span class="prompt-scale-label">{{.Label}}</span>{{end}}
<div class="prompt-scale-buttons">
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="0">0</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="1">1</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="2">2</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="3">3</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="4">4</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="5">5</button>
</div>
<input type="hidden" name="field_{{.Key}}" value="">
</div>
{{else if eq .Type "select"}}
<div class="prompt-field-inline">
{{if .Label}}<span class="prompt-scale-label">{{.Label}}</span>{{end}}
<select name="field_{{.Key}}" class="prompt-select">
<option value="">--</option>
{{range .Options}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</div>
{{else}}
<input type="text" name="field_{{.Key}}" value="{{.Value}}" class="prompt-input-text">
{{end}} {{end}}
{{end}} {{end}}
{{end}}
</div>
{{end}}
{{if .HasResponse}}
<div class="prompt-saved-footer">
<span class="prompt-saved-time">Last: {{if .LastResponseFormatted}}{{.LastResponseFormatted}}{{end}}</span>
<span class="prompt-saved-value">{{.LastResponseRaw}}</span>
</div>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{else}} {{else}}
{{range .Fields}} <div class="empty-state" style="padding: 48px 24px; text-align: center;">
<div class="prompt-field-row"> <p style="font-size: 1.1rem; margin-bottom: 12px;">✨ No tracking prompts yet</p>
{{if .Label}}<label class="prompt-field-label">{{.Label}}</label>{{end}} <p style="color: var(--text-muted); margin-bottom: 24px;">Start by logging something below, and the system will learn your patterns.</p>
{{if eq .Type "number"}} <p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Examples:</p>
<input type="number" name="field_{{.Key}}" <ul style="list-style: none; padding: 0; color: var(--text-muted); font-size: 0.9rem; line-height: 2;">
{{if .Min}}min="{{.Min}}"{{end}} <li>"I take vitamin D 5000 IU every morning"</li>
{{if .Max}}max="{{.Max}}"{{end}} <li>"Blood pressure 120/80"</li>
{{if .Step}}step="{{.Step}}"{{end}} <li>"Walked 30 minutes today"</li>
{{if .Value}}value="{{.Value}}"{{end}} </ul>
class="prompt-input-number">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
{{else if eq .Type "checkbox"}}
<label class="prompt-checkbox">
<input type="checkbox" name="field_{{.Key}}" value="1">
<span class="prompt-checkbox-box"></span>
</label>
{{else if eq .Type "scale"}}
<div class="prompt-scale-buttons">
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="0">0</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="1">1</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="2">2</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="3">3</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="4">4</button>
<button type="button" class="prompt-scale-btn" data-field="{{.Key}}" data-value="5">5</button>
</div>
<input type="hidden" name="field_{{.Key}}" value="">
{{else if eq .Type "select"}}
<select name="field_{{.Key}}" class="prompt-select">
<option value="">--</option>
{{range .Options}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
{{else}}
<input type="text" name="field_{{.Key}}" class="prompt-input-text">
{{end}}
</div>
{{end}}
{{end}}
{{else}}
<textarea name="response_raw" class="prompt-textarea" rows="3" placeholder="Type your notes..."></textarea>
{{end}}
</div>
</form>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="empty-state">
<p>All caught up! No items due right now.</p>
<a href="/dossier/{{.TargetHex}}/prompts?all=1" class="btn btn-secondary">View all items</a>
</div> </div>
{{end}} {{end}}
@ -219,6 +242,7 @@
position: relative; position: relative;
padding: 20px; padding: 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
transition: opacity 0.3s ease;
} }
.prompt-item:last-child { .prompt-item:last-child {
border-bottom: none; border-bottom: none;
@ -274,6 +298,18 @@
align-items: flex-start; align-items: flex-start;
margin-bottom: 12px; 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 { .prompt-question {
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
@ -484,8 +520,24 @@
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
} }
.entry-item { .prompt-freeform {
background: #f9f9f9; 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 { .entry-readonly .prompt-field-row {
display: flex; display: flex;
@ -502,6 +554,64 @@
font-weight: 600; font-weight: 600;
color: var(--accent); 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 { @keyframes slideIn {
from { from {
@ -517,15 +627,27 @@
<script> <script>
const targetHex = '{{.TargetHex}}'; const targetHex = '{{.TargetHex}}';
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Update keyboard hint based on platform
document.addEventListener('DOMContentLoaded', () => {
const hint = document.getElementById('kbd-hint');
if (hint && isMac) {
hint.textContent = 'or press ⌘+Enter';
}
});
document.querySelectorAll('.prompt-item').forEach(item => { document.querySelectorAll('.prompt-item').forEach(item => {
item.addEventListener('focusout', (e) => { // Only auto-save on blur for number inputs (quick entry)
item.querySelectorAll('input[type=number]').forEach(input => {
input.addEventListener('blur', () => {
setTimeout(() => { setTimeout(() => {
if (!item.contains(document.activeElement)) { if (!item.contains(document.activeElement)) {
saveItem(item); saveItem(item);
} }
}, 100); }, 100);
}); });
});
item.querySelectorAll('input[type=checkbox]').forEach(cb => { item.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => saveItem(item)); cb.addEventListener('change', () => saveItem(item));
@ -724,6 +846,15 @@ async function saveItem(item) {
if (!hasValue) return; if (!hasValue) return;
// Show saving state
const saveBtn = item.querySelector('.btn-save, .btn-primary');
const originalText = saveBtn ? saveBtn.textContent : '';
if (saveBtn) {
saveBtn.classList.add('saving');
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
}
try { try {
const res = await fetch('/dossier/' + targetHex + '/prompts/respond', { const res = await fetch('/dossier/' + targetHex + '/prompts/respond', {
method: 'POST', method: 'POST',
@ -742,19 +873,152 @@ async function saveItem(item) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
form.style.display = 'none'; // For freeform prompts, just clear and show notification
const saved = item.querySelector('.prompt-saved'); if (item.classList.contains('prompt-freeform')) {
saved.querySelector('.prompt-saved-value').textContent = displayValue; form.querySelector('textarea').value = '';
saved.style.display = 'block'; showNotification('✓ Saved', 'success');
// If LLM generated a new prompt, add it to the deck // If LLM generated a new prompt, add it as a pending card
if (data.new_prompt) { if (data.new_prompt) {
addNewPromptCard(data.new_prompt); addPendingCard(data.new_prompt);
showNotification('✓ Saved! Added new tracking prompt.', 'success');
}
return;
}
// For pending prompts, convert to filled card
if (item.classList.contains('prompt-pending')) {
// Get the question from the prompt
const question = item.querySelector('.prompt-question').textContent;
const category = item.querySelector('.prompt-category')?.textContent || '';
// Create filled card
const filledCard = createFilledCard({
question: question,
category: category,
value: displayValue,
time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
promptId: promptId
});
// Insert filled card before upcoming section (or at end of today section)
const promptList = document.querySelector('.prompt-list');
const freeformCard = promptList.querySelector('.prompt-freeform');
if (freeformCard && freeformCard.nextElementSibling) {
freeformCard.nextElementSibling.insertAdjacentHTML('beforebegin', filledCard);
} else {
promptList.insertAdjacentHTML('beforeend', filledCard);
}
// Remove the pending card
item.style.opacity = '0';
setTimeout(() => item.remove(), 300);
showNotification('✓ Saved', 'success');
}
} else {
showNotification('Failed to save. Please try again.', 'error');
if (saveBtn) {
saveBtn.classList.remove('saving');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
} }
} }
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Save failed:', err);
showNotification('Failed to save. Please try again.', 'error');
if (saveBtn) {
saveBtn.classList.remove('saving');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
} }
} }
}
function createFilledCard(data) {
const now = new Date();
return `
<div class="prompt-item prompt-filled" style="animation: slideIn 0.3s ease-out;">
<a href="#" class="prompt-dismiss" onclick="deleteEntry('${data.promptId}'); return false;" title="Delete">✕</a>
<div class="prompt-header">
${data.category ? `<span class="prompt-category">${data.category}</span>` : ''}
<span class="prompt-question">${data.question}</span>
<span class="prompt-saved-time">${data.time}</span>
</div>
<div class="prompt-body entry-readonly">
<div class="prompt-field-row">
<span class="entry-value">${data.value}</span>
</div>
</div>
<div class="prompt-saved-footer">
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
</div>
</div>`;
}
function addPendingCard(prompt) {
// Add a new pending card (from AI prompt generation)
const promptList = document.querySelector('.prompt-list');
const freeformCard = promptList.querySelector('.prompt-freeform');
let fieldsHtml = '';
if (prompt.input_config && prompt.input_config.fields) {
const field = prompt.input_config.fields[0];
if (field.type === 'number') {
fieldsHtml = `
<div class="prompt-input-row">
<input type="number" name="field_${field.key}" class="prompt-input-number"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
${field.unit ? `<span class="prompt-unit">${field.unit}</span>` : ''}
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
</div>`;
} else if (field.type === 'checkbox') {
fieldsHtml = `
<label class="prompt-checkbox">
<input type="checkbox" name="field_${field.key}" value="1">
<span class="prompt-checkbox-box"></span>
<span class="prompt-checkbox-label">Yes</span>
</label>`;
}
}
const cardHtml = `
<div class="prompt-item prompt-pending" data-prompt-id="${prompt.id}" style="animation: slideIn 0.3s ease-out;">
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '${prompt.id}'); return false;" title="Don't ask again">✕</a>
<div class="dismiss-confirm">
<span>Stop tracking?</span>
<a href="#" onclick="confirmDismiss('${prompt.id}'); return false;">Yes</a>
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
</div>
<form class="prompt-form" data-prompt-id="${prompt.id}">
<div class="prompt-header">
<span class="prompt-category">${prompt.category}</span>
<span class="prompt-question">${prompt.question}</span>
<span class="prompt-due">now</span>
</div>
<div class="prompt-body">
${fieldsHtml}
</div>
</form>
</div>`;
// Insert after freeform card
if (freeformCard) {
freeformCard.insertAdjacentHTML('afterend', cardHtml);
}
}
function showNotification(message, type = 'success') {
const notif = document.createElement('div');
notif.className = `notification notification-${type}`;
notif.textContent = message;
document.body.appendChild(notif);
setTimeout(() => notif.classList.add('show'), 10);
setTimeout(() => {
notif.classList.remove('show');
setTimeout(() => notif.remove(), 300);
}, 3000);
}
</script> </script>
{{end}} {{end}}

185
portal/templates/terms.tmpl Normal file
View File

@ -0,0 +1,185 @@
{{define "terms"}}
<style>
.terms-container {
max-width: 1200px;
margin: 0 auto;
padding: 48px 24px 80px;
}
.terms-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 48px;
margin-bottom: 24px;
}
.terms-card h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
}
.terms-card .intro {
font-size: 1.15rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 0;
}
.terms-card h2 {
font-size: 1.4rem;
font-weight: 600;
color: var(--text);
margin-top: 0;
margin-bottom: 24px;
}
.terms-card h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text);
margin-top: 24px;
margin-bottom: 8px;
}
.terms-card h3:first-child { margin-top: 0; }
.terms-card p {
font-size: 1rem;
font-weight: 300;
color: var(--text-muted);
line-height: 1.8;
margin-bottom: 16px;
}
.terms-card p:last-child { margin-bottom: 0; }
.terms-card strong {
font-weight: 600;
color: var(--text);
}
.terms-card a {
color: var(--accent);
}
.inou-brand {
font-weight: 700;
color: var(--accent);
}
/* Mobile */
@media (max-width: 768px) {
.terms-container { padding: 24px 16px 48px; }
.terms-card { padding: 32px 24px; }
.terms-card h1 { font-size: 2rem; }
.terms-card .intro { font-size: 1.05rem; }
.terms-card h2 { font-size: 1.25rem; }
.terms-card h3 { font-size: 1rem; }
.terms-card p { font-size: 0.95rem; }
}
@media (max-width: 480px) {
.terms-container { padding: 16px 12px 32px; }
.terms-card { padding: 24px 16px; }
.terms-card h1 { font-size: 1.75rem; }
.terms-card .intro { font-size: 1rem; }
.terms-card h2 { font-size: 1.15rem; }
.terms-card p { font-size: 0.9rem; }
}
</style>
<div class="terms-container">
<div class="terms-card">
<h1>Terms of Service</h1>
<p class="intro">These terms govern your use of <span class="inou-brand">inou</span>. By creating an account, you agree to them. If you don't agree, don't use the service.</p>
</div>
<div class="terms-card">
<h2>The service</h2>
<h3>What inou is.</h3>
<p><span class="inou-brand">inou</span> 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.</p>
<h3>What inou is not.</h3>
<p><span class="inou-brand">inou</span> is not a medical device. It is not intended for clinical diagnosis, treatment, cure, or prevention of any disease or medical condition. <span class="inou-brand">inou</span> 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.</p>
</div>
<div class="terms-card">
<h2>Your account</h2>
<h3>Account requirements.</h3>
<p>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.</p>
<h3>Your data, your responsibility.</h3>
<p>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.</p>
</div>
<div class="terms-card">
<h2>Our responsibilities</h2>
<h3>What we provide.</h3>
<p>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.</p>
<h3>What we don't guarantee.</h3>
<p>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.</p>
</div>
<div class="terms-card">
<h2>Acceptable use</h2>
<h3>Don't.</h3>
<p>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.</p>
<h3>If you do.</h3>
<p>We may suspend or terminate your account. In cases of illegal activity, we will cooperate with law enforcement.</p>
</div>
<div class="terms-card">
<h2>Payment</h2>
<h3>Pricing.</h3>
<p>Plans and pricing are described on our <a href="/pricing">pricing page</a>. 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.</p>
<h3>Refunds.</h3>
<p>If you're unhappy, contact us. We handle refunds on a case-by-case basis.</p>
</div>
<div class="terms-card">
<h2>Termination</h2>
<h3>You can leave anytime.</h3>
<p>Delete your account, and all your data is permanently destroyed. No notice required. No penalty.</p>
<h3>We can end things too.</h3>
<p>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.</p>
</div>
<div class="terms-card">
<h2>Liability</h2>
<h3>Limitation of liability.</h3>
<p>To the maximum extent permitted by law, <span class="inou-brand">inou</span>'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.</p>
<h3>Indemnification.</h3>
<p>You agree to indemnify <span class="inou-brand">inou</span> against claims arising from your use of the service, your data, or your violation of these terms.</p>
</div>
<div class="terms-card">
<h2>Governing law</h2>
<p>These terms are governed by the laws of the State of Florida, United States. Disputes will be resolved in the courts of Florida.</p>
</div>
<div class="terms-card">
<h2>Changes</h2>
<p>We may update these terms. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p>
<p>Questions: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
<p>Last updated: February 8, 2026.</p>
</div>
{{template "footer"}}
</div>
{{end}}