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
}
// List all studies directly (top-level entries with type="study")
entries, err := lib.EntryRootsByType(dossierID, "study")
// List all studies (category=imaging, type=study)
entries, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

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

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)
// ============================================================================
// getOrCreateStudy finds existing study or creates new one (root-level entry)
// getOrCreateStudy finds existing study or creates new one (child of imaging category root)
func getOrCreateStudy(data []byte, dossierID string) (string, error) {
studyUID := readStringTag(data, 0x0020, 0x000D)
if id, ok := studyCache[studyUID]; ok {
return id, nil
}
// Query for existing study using V2 API
studies, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryImaging, &lib.EntryFilter{ // nil ctx - import tool
DossierID: dossierID,
Type: "study",
})
// Query for existing study by category+type (parent-agnostic)
studies, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
if err == nil {
for _, s := range studies {
if s.Value == studyUID {
@ -464,6 +461,12 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
}
}
// Get imaging category root (create if needed)
catRootID, err := lib.EnsureCategoryRoot(dossierID, lib.CategoryImaging)
if err != nil {
return "", fmt.Errorf("ensure imaging category root: %w", err)
}
// Extract study metadata
patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010))
studyDesc := readStringTag(data, 0x0008, 0x1030)
@ -500,7 +503,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
e := &lib.Entry{
EntryID: lib.NewID(),
DossierID: dossierID,
ParentID: "", // root-level entry
ParentID: catRootID, // child of imaging category root
Category: lib.CategoryImaging,
Type: "study",
Value: studyUID,
@ -508,7 +511,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
Timestamp: time.Now().Unix(),
Data: string(dataJSON),
}
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
if err := lib.EntryWrite(nil, e); err != nil {
return "", err
}
studyCache[studyUID] = e.EntryID
@ -573,6 +576,7 @@ func getOrCreateSeries(data []byte, dossierID, studyID string) (string, error) {
Timestamp: time.Now().Unix(),
Tags: seriesDesc,
Data: string(dataJSON),
SearchKey: modality,
}
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
return "", err

View File

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

View File

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

View File

@ -24,47 +24,128 @@ type RoleTemplate struct {
// SystemRoles defines all available role templates
var SystemRoles = []RoleTemplate{
{
Name: "Family",
Description: "Full access for family members",
Name: "Parent/Guardian",
Description: "Full access to child's data",
Grants: []RoleGrant{
{Category: 0, Ops: "rwdm"}, // Full access to everything
{Category: 0, Ops: "rwdm"},
},
},
{
Name: "Spouse/Partner",
Description: "Full access to partner's data",
Grants: []RoleGrant{
{Category: 0, Ops: "rwdm"},
},
},
{
Name: "Sibling",
Description: "View and add notes",
Grants: []RoleGrant{
{Category: 0, Ops: "r"},
{Category: CategoryNote, Ops: "rw"},
},
},
{
Name: "Extended Family",
Description: "View only",
Grants: []RoleGrant{
{Category: 0, Ops: "r"},
},
},
{
Name: "Doctor",
Description: "Read/write access for healthcare providers",
Description: "Full clinical access",
Grants: []RoleGrant{
{Category: 0, Ops: "rw"}, // Read/write to everything
{Category: 0, Ops: "rw"},
},
},
{
Name: "Specialist",
Description: "Clinical data read, write imaging/labs/docs",
Grants: []RoleGrant{
{Category: 0, Ops: "r"},
{Category: CategoryImaging, Ops: "rw"},
{Category: CategoryLab, Ops: "rw"},
{Category: CategoryDocument, Ops: "rw"},
{Category: CategoryAssessment, Ops: "rw"},
},
},
{
Name: "Caregiver",
Description: "Read/write access for caregivers",
Description: "Daily care access",
Grants: []RoleGrant{
{Category: 0, Ops: "rw"}, // Read/write to everything
{Category: 0, Ops: "rw"},
},
},
{
Name: "Physical Therapist",
Description: "Exercise, vitals, imaging",
Grants: []RoleGrant{
{Category: CategoryImaging, Ops: "r"},
{Category: CategoryLab, Ops: "r"},
{Category: CategoryVital, Ops: "rw"},
{Category: CategoryExercise, Ops: "rw"},
{Category: CategoryNote, Ops: "rw"},
},
},
{
Name: "Nutritionist",
Description: "Diet, supplements, labs",
Grants: []RoleGrant{
{Category: CategoryLab, Ops: "r"},
{Category: CategoryVital, Ops: "r"},
{Category: CategoryNutrition, Ops: "rw"},
{Category: CategorySupplement, Ops: "rw"},
},
},
{
Name: "Trainer",
Description: "Read-only with write access to exercise and nutrition",
Description: "Exercise, nutrition, vitals",
Grants: []RoleGrant{
{Category: 0, Ops: "r"}, // Read everything
{Category: CategoryExercise, Ops: "rw"}, // Write exercise
{Category: CategoryNutrition, Ops: "rw"}, // Write nutrition
{Category: CategoryVital, Ops: "r"},
{Category: CategoryExercise, Ops: "rw"},
{Category: CategoryNutrition, Ops: "rw"},
},
},
{
Name: "Therapist",
Description: "Mental health, notes, assessments",
Grants: []RoleGrant{
{Category: CategoryLab, Ops: "r"},
{Category: CategoryVital, Ops: "r"},
{Category: CategoryNote, Ops: "rw"},
{Category: CategorySymptom, Ops: "rw"},
{Category: CategoryAssessment, Ops: "rw"},
},
},
{
Name: "Pharmacist",
Description: "Medications, labs, genome",
Grants: []RoleGrant{
{Category: CategoryMedication, Ops: "r"},
{Category: CategoryLab, Ops: "r"},
{Category: CategoryGenome, Ops: "r"},
},
},
{
Name: "Friend",
Description: "Read-only access",
Description: "View only",
Grants: []RoleGrant{
{Category: 0, Ops: "r"}, // Read everything
{Category: 0, Ops: "r"},
},
},
{
Name: "Researcher",
Description: "Read-only access for research purposes",
Description: "View only for research",
Grants: []RoleGrant{
{Category: 0, Ops: "r"}, // Read everything
{Category: 0, Ops: "r"},
},
},
{
Name: "Emergency",
Description: "Emergency read access",
Grants: []RoleGrant{
{Category: 0, Ops: "r"},
},
},
}
@ -135,30 +216,9 @@ func ApplyRoleTemplate(dossierID, granteeID, roleName string) error {
return nil
}
// findOrCreateCategoryRoot finds or creates a root entry for category-level grants
// This is a virtual entry that serves as parent for all entries of that category
// findOrCreateCategoryRoot is an alias for EnsureCategoryRoot (in access.go)
func findOrCreateCategoryRoot(dossierID string, category int) (string, error) {
// Look for existing category root entry (type = "category_root", use empty string for system context)
entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{
DossierID: dossierID,
Type: "category_root",
Limit: 1,
})
if err == nil && len(entries) > 0 {
return entries[0].EntryID, nil
}
// Create virtual category root entry
entry := &Entry{
DossierID: dossierID,
Category: category,
Type: "category_root",
Value: CategoryName(category),
}
if err := EntryWrite(nil, entry); err != nil {
return "", err
}
return entry.EntryID, nil
return EnsureCategoryRoot(dossierID, category)
}
// RevokeRole removes all grants with the specified role for a grantee on a dossier

View File

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

View File

@ -43,6 +43,7 @@ type EntryFilter struct {
DossierID string
Type string
Value string
SearchKey string
FromDate int64
ToDate int64
Limit int
@ -60,7 +61,7 @@ func EntryWrite(ctx *AccessContext, entries ...*Entry) error {
return fmt.Errorf("entry missing dossier_id")
}
// Check write on parent (or root if no parent)
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, 'w'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, e.Category, 'w'); err != nil {
return err
}
}
@ -84,7 +85,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error {
if err != nil {
continue // Entry doesn't exist, skip
}
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'd'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'd'); err != nil {
return err
}
}
@ -94,7 +95,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error {
// EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root.
func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil {
return err
}
@ -118,7 +119,7 @@ func EntryGet(ctx *AccessContext, id string) (*Entry, error) {
}
// RBAC: Check read permission
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil {
return nil, err
}
@ -147,7 +148,7 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
}
}
if dossierID != "" {
if err := checkAccess(accessorID, dossierID, parent, 'r'); err != nil {
if err := checkAccess(accessorID, dossierID, parent, category, 'r'); err != nil {
return nil, err
}
}
@ -180,6 +181,10 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
q += " AND value = ?"
args = append(args, CryptoEncrypt(f.Value))
}
if f.SearchKey != "" {
q += " AND search_key = ?"
args = append(args, CryptoEncrypt(f.SearchKey))
}
if f.FromDate > 0 {
q += " AND timestamp >= ?"
args = append(args, f.FromDate)
@ -219,7 +224,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
for _, d := range dossiers {
if d.DossierID != "" {
// Update - need manage permission (unless creating own or system)
if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 'm'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 0, 'm'); err != nil {
return err
}
}
@ -245,7 +250,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
func DossierRemove(ctx *AccessContext, ids ...string) error {
// RBAC: Check manage permission for each dossier
for _, id := range ids {
if err := checkAccess(accessorIDFromContext(ctx), id, "", 'm'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'm'); err != nil {
return err
}
}
@ -255,7 +260,7 @@ func DossierRemove(ctx *AccessContext, ids ...string) error {
// DossierGet retrieves a dossier. Requires read permission.
func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
// RBAC: Check read permission
if err := checkAccess(accessorIDFromContext(ctx), id, "", 'r'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'r'); err != nil {
return nil, err
}
@ -552,7 +557,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
}
// RBAC: Check read permission
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil {
return nil, err
}
@ -637,7 +642,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
// ObjectWrite encrypts and writes data to the object store. Requires write permission.
func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error {
// RBAC: Check write permission
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'w'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'w'); err != nil {
return err
}
@ -652,7 +657,7 @@ func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) err
// ObjectRead reads and decrypts data from the object store. Requires read permission.
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
// RBAC: Check read permission
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'r'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'r'); err != nil {
return nil, err
}
@ -675,7 +680,7 @@ func objectReadRaw(dossierID, entryID string) ([]byte, error) {
// ObjectRemove deletes an object from the store. Requires delete permission.
func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
// RBAC: Check delete permission
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'd'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'd'); err != nil {
return err
}
return os.Remove(ObjectPath(dossierID, entryID))
@ -684,7 +689,7 @@ func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
// ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission.
func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil {
return err
}
return os.RemoveAll(filepath.Join(ObjectDir, dossierID))
@ -841,6 +846,48 @@ func MigrateOldAccess() int {
return migrated
}
// MigrateStudiesToCategoryRoot moves orphan studies (parent_id="") under their
// imaging category_root entry. Idempotent — skips studies already parented.
func MigrateStudiesToCategoryRoot() int {
// Find all imaging entries with empty parent_id, filter to studies in Go
var all []*Entry
err := Query(
"SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')",
[]any{CategoryImaging}, &all)
if err != nil {
return 0
}
var studies []*Entry
for _, e := range all {
if e.Type == "study" {
studies = append(studies, e)
}
}
if len(studies) == 0 {
return 0
}
migrated := 0
catRoots := map[string]string{} // dossier_id → category_root entry_id
for _, s := range studies {
rootID, ok := catRoots[s.DossierID]
if !ok {
rootID, err = EnsureCategoryRoot(s.DossierID, CategoryImaging)
if err != nil {
continue
}
catRoots[s.DossierID] = rootID
}
s.ParentID = rootID
if err := Save("entries", s); err == nil {
migrated++
}
}
return migrated
}
// AccessGrantGet retrieves a single access grant by ID
func AccessGrantGet(id string) (*Access, error) {
a := &Access{}

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",
"/security",
"/legal/dpa",
"/legal/terms",
"/demo",
"/oauth/authorize",
"/oauth/token",

View File

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

View File

@ -378,13 +378,14 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
},
{
"name": "query_entries",
"description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: LOINC codes (e.g., '2947-0') provide best accuracy. Use get_categories first to discover available categories.",
"description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: Use search_key with LOINC code (e.g., '2947-0') for fast, accurate results. Use get_categories first to discover available categories.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
"category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"},
"type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., LOINC code for labs)"},
"type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., test name for labs)"},
"search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs (e.g., '2947-0'), gene name for genome (e.g., 'MTHFR')"},
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"},
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
@ -526,11 +527,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
}
category, _ := params.Arguments["category"].(string)
typ, _ := params.Arguments["type"].(string)
searchKey, _ := params.Arguments["search_key"].(string)
parent, _ := params.Arguments["parent"].(string)
from, _ := params.Arguments["from"].(string)
to, _ := params.Arguments["to"].(string)
limit, _ := params.Arguments["limit"].(float64)
result, err := mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to, int(limit))
result, err := mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to, int(limit))
if err != nil {
sendMCPError(w, req.ID, -32000, err.Error())
return

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

View File

@ -121,12 +121,26 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
}
json.NewDecoder(resp.Body).Decode(&apiPrompts)
// Helper to translate category name
translateCategory := func(cat string) string {
switch cat {
case "supplement": return T(lang, "section_supplements")
case "medication": return T(lang, "section_medications")
case "vital": return T(lang, "section_vitals")
case "exercise": return T(lang, "section_exercise")
case "symptom": return T(lang, "section_symptoms")
case "nutrition": return T(lang, "section_nutrition")
case "note": return T(lang, "section_notes")
default: return cat
}
}
// Convert to view models
var prompts []PromptView
for _, ap := range apiPrompts {
pv := PromptView{
ID: ap.ID,
Category: ap.Category,
Category: translateCategory(ap.Category),
Type: ap.Type,
Question: ap.Question,
NextAsk: ap.NextAsk,

View File

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

View File

@ -316,20 +316,31 @@ function renderFilterChart(card, table, q) {
return;
}
// Calculate global time range across all charts for alignment
let globalTMin = Infinity, globalTMax = -Infinity;
for (const [, s] of chartable) {
s.points.sort((a, b) => a.date - b.date);
if (s.points.length > 0) {
globalTMin = Math.min(globalTMin, s.points[0].date.getTime());
globalTMax = Math.max(globalTMax, s.points[s.points.length - 1].date.getTime());
}
}
// Extend to today if last point is in the past
globalTMax = Math.max(globalTMax, new Date().getTime());
wrapper.style.display = '';
wrapper.classList.remove('collapsed');
let html = '';
for (const [loinc, s] of chartable) {
s.points.sort((a, b) => a.date - b.date);
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
const fullName = loincNames[loinc] || s.abbr;
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr;
html += buildSVGChart(displayName, s.unit, s.points, s.abbr);
html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax);
}
body.innerHTML = html;
}
function buildSVGChart(name, unit, points, abbr) {
function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
const W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 };
const pw = W - PAD.left - PAD.right;
const ph = H - PAD.top - PAD.bottom;
@ -349,7 +360,9 @@ function buildSVGChart(name, unit, points, abbr) {
// Never show negative Y when all values are >= 0
if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin);
const tMin = points[0].date.getTime(), tMax = Math.max(new Date().getTime(), points[points.length-1].date.getTime());
// Use global time range if provided, otherwise fall back to local range
const tMin = globalTMin !== undefined ? globalTMin : points[0].date.getTime();
const tMax = globalTMax !== undefined ? globalTMax : Math.max(new Date().getTime(), points[points.length-1].date.getTime());
const tRange = tMax - tMin || 1;
const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw;

View File

@ -127,7 +127,7 @@
<h2>Data we process</h2>
<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>
<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">
<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>This agreement was last updated on January 21, 2026.</p>
<p>This agreement was last updated on February 8, 2026.</p>
</div>
{{template "footer"}}

View File

@ -1,7 +1,7 @@
{{define "edit_rbac"}}
<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 class="data-card" style="padding: 48px; max-width: 800px; width: 100%;">
<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: 780px; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
<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">
<input type="hidden" name="action" value="update">
<input type="hidden" name="role" id="roleHidden" value="{{.SelectedRole}}">
<!-- Role Selector -->
<div style="margin-bottom: 32px;">
<div class="form-group">
<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>
<div style="margin-bottom: 24px;">
<label style="font-weight: 600; margin-bottom: 8px; display: block;">Role</label>
<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}}
<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}}
</select>
</div>
</div>
<!-- Base Operations -->
<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;">Base Permissions</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Operations that apply across all data</p>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_r" name="op_r" value="1" {{if .HasRead}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Read</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">View all data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_w" name="op_w" value="1" {{if .HasWrite}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Write</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Add & update data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_d" name="op_d" value="1" {{if .HasDelete}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Delete</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Remove data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_m" name="op_m" value="1" {{if .HasManage}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Manage</span>
<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>
<!-- Category Permission Table -->
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem; table-layout: fixed;">
<colgroup>
<col style="width: auto;">
<col style="width: 72px;">
<col style="width: 72px;">
<col style="width: 72px;">
<col style="width: 72px;">
<col style="width: 72px;">
</colgroup>
<thead>
<tr style="border-bottom: 2px solid var(--border-light);">
<th style="text-align: left; padding: 8px 12px; font-weight: 600;">Category</th>
<th style="text-align: center; padding: 8px 4px; font-weight: 600;">All</th>
<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>
<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>
<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>
<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>
</tr>
</thead>
<tbody>
{{range .CategoriesRBAC}}
<tr style="border-bottom: 1px solid var(--border-light);" data-cat="{{.ID}}">
<td style="padding: 6px 12px; text-transform: capitalize;">{{.Name}}</td>
<td style="text-align: center; padding: 6px 4px;"><input type="checkbox" class="row-toggle"></td>
<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>
<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>
<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>
<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>
</tr>
{{end}}
</div>
</div>
</tbody>
</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>
<button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button>
</div>
@ -120,49 +79,106 @@
</div>
</div>
<style>
.col-toggle { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
</style>
<script>
document.getElementById('roleSelect').addEventListener('change', function() {
if (!this.value) return;
(function() {
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;
const grants = JSON.parse(grantsJSON);
// Clear all checkboxes first
document.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
allCbs.forEach(cb => cb.checked = false);
// Apply grants
grants.forEach(grant => {
const ops = grant.Ops || '';
let rootOps = '';
grants.forEach(g => { if (g.Category === 0) rootOps = g.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) {
// Root level - apply to base ops
if (ops.includes('r')) document.getElementById('op_r').checked = true;
if (ops.includes('w')) document.getElementById('op_w').checked = true;
if (ops.includes('d')) document.getElementById('op_d').checked = true;
if (ops.includes('m')) document.getElementById('op_m').checked = true;
} else {
// Category specific
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;
}
function setOps(catID, ops) {
const row = document.querySelector('tr[data-cat="' + catID + '"]');
if (!row) return;
const cbs = row.querySelectorAll('.perm-cb');
if (cbs[0]) cbs[0].checked = ops.includes('r');
if (cbs[1]) cbs[1].checked = ops.includes('w');
if (cbs[2]) cbs[2].checked = ops.includes('d');
if (cbs[3]) cbs[3].checked = ops.includes('m');
}
// --- 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>
{{end}}

View File

@ -3,6 +3,7 @@
<div class="sg-footer-left">
<span>© 2026</span>
<a href="/privacy-policy">Privacy</a>
<a href="/legal/terms">Terms</a>
<a href="/pricing">Pricing</a>
</div>
<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>
</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">
<h2>What we promise</h2>
@ -192,7 +198,7 @@
<p>Found a mistake? You can correct it yourself, or ask us to help.</p>
<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>
<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>
</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">
<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>
@ -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 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>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>
{{template "footer"}}

View File

@ -1,12 +1,17 @@
{{define "prompts"}}
<div class="sg-container">
<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 .Success}}<div class="msg msg-success">{{.Success}}</div>{{end}}
{{if .Prompts}}
{{if or .DuePrompts .UpcomingPrompts .Entries}}
<div class="data-card">
<!-- Section header -->
<div class="prompt-section-header">
@ -18,10 +23,86 @@
</div>
<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}}
<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>
<div class="prompt-header">
<span class="prompt-category">{{.Category}}</span>
<span class="prompt-question">{{.Question}}</span>
<span class="prompt-saved-time">{{.TimeFormatted}}</span>
</div>
@ -43,136 +124,78 @@
<div class="prompt-saved-footer">
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
</div>
{{if .SourceInput}}<div class="prompt-source"> "{{.SourceInput}}"</div>{{end}}
{{if .SourceInput}}<div class="prompt-source">Created from: "{{.SourceInput}}"</div>{{end}}
</div>
{{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 := .}}
<div class="prompt-item{{if not .IsDue}} prompt-item-future{{end}}" data-prompt-id="{{.ID}}">
<!-- Dismiss button -->
<div class="prompt-item prompt-item-future" 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>
<!-- 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">
<span class="prompt-category">{{.Category}}</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 class="prompt-body">
{{/* Show preview of what the input will look like */}}
{{if .Fields}}
<div class="prompt-body prompt-preview">
{{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}}
{{if .Value}}value="{{.Value}}"{{end}}
class="prompt-input-number">
<input type="number" disabled placeholder="Amount" class="prompt-input-number">
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
</div>
{{else if eq .Type "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-label">{{if .Label}}{{.Label}}{{else}}Yes{{end}}</span>
<span class="prompt-checkbox-label">Yes</span>
</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}}
</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}}
{{range .Fields}}
<div class="prompt-field-row">
{{if .Label}}<label class="prompt-field-label">{{.Label}}</label>{{end}}
{{if eq .Type "number"}}
<input type="number" name="field_{{.Key}}"
{{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}}
{{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 class="empty-state" style="padding: 48px 24px; text-align: center;">
<p style="font-size: 1.1rem; margin-bottom: 12px;">✨ No tracking prompts yet</p>
<p style="color: var(--text-muted); margin-bottom: 24px;">Start by logging something below, and the system will learn your patterns.</p>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Examples:</p>
<ul style="list-style: none; padding: 0; color: var(--text-muted); font-size: 0.9rem; line-height: 2;">
<li>"I take vitamin D 5000 IU every morning"</li>
<li>"Blood pressure 120/80"</li>
<li>"Walked 30 minutes today"</li>
</ul>
</div>
{{end}}
@ -219,6 +242,7 @@
position: relative;
padding: 20px;
border-bottom: 1px solid var(--border);
transition: opacity 0.3s ease;
}
.prompt-item:last-child {
border-bottom: none;
@ -274,6 +298,18 @@
align-items: flex-start;
margin-bottom: 12px;
}
.prompt-category {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin-right: 8px;
padding: 2px 8px;
background: rgba(198, 93, 7, 0.1);
border-radius: 4px;
}
.prompt-question {
font-size: 1rem;
font-weight: 500;
@ -484,8 +520,24 @@
color: var(--text-muted);
font-style: italic;
}
.entry-item {
background: #f9f9f9;
.prompt-freeform {
background: #fefce8;
border-left: 4px solid #eab308;
}
.prompt-pending {
background: #fff;
border-left: 4px solid var(--accent);
}
.prompt-filled {
background: #f0fdf4;
border-left: 4px solid #16a34a;
}
.prompt-preview {
opacity: 0.6;
}
.prompt-preview input[disabled] {
cursor: not-allowed;
background: #f9fafb;
}
.entry-readonly .prompt-field-row {
display: flex;
@ -502,6 +554,64 @@
font-weight: 600;
color: var(--accent);
}
.btn-save {
padding: 6px 16px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-save:hover {
background: #B45309;
transform: translateY(-1px);
}
.btn-save:active {
transform: translateY(0);
}
.btn-save.saving {
opacity: 0.6;
cursor: wait;
}
.prompt-actions {
display: flex;
gap: 12px;
align-items: center;
margin-top: 12px;
}
.prompt-hint {
font-size: 0.85rem;
color: var(--text-muted);
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-size: 0.95rem;
opacity: 0;
transform: translateY(-10px);
transition: all 0.3s ease;
z-index: 1000;
}
.notification.show {
opacity: 1;
transform: translateY(0);
}
.notification-success {
border-left: 4px solid #16a34a;
}
.notification-error {
border-left: 4px solid #dc2626;
color: #dc2626;
}
@keyframes slideIn {
from {
@ -517,15 +627,27 @@
<script>
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 => {
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(() => {
if (!item.contains(document.activeElement)) {
saveItem(item);
}
}, 100);
});
});
item.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => saveItem(item));
@ -724,6 +846,15 @@ async function saveItem(item) {
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 {
const res = await fetch('/dossier/' + targetHex + '/prompts/respond', {
method: 'POST',
@ -742,19 +873,152 @@ async function saveItem(item) {
if (res.ok) {
const data = await res.json();
form.style.display = 'none';
const saved = item.querySelector('.prompt-saved');
saved.querySelector('.prompt-saved-value').textContent = displayValue;
saved.style.display = 'block';
// For freeform prompts, just clear and show notification
if (item.classList.contains('prompt-freeform')) {
form.querySelector('textarea').value = '';
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) {
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) {
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>
{{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}}