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:
parent
7192f39bc1
commit
35e9e2a84b
|
|
@ -61,8 +61,8 @@ func handleStudies(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all studies directly (top-level entries with type="study")
|
// List all studies (category=imaging, type=study)
|
||||||
entries, err := lib.EntryRootsByType(dossierID, "study")
|
entries, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,9 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) {
|
||||||
category = lib.CategoryFromString[cat]
|
category = lib.CategoryFromString[cat]
|
||||||
}
|
}
|
||||||
filter := &lib.EntryFilter{
|
filter := &lib.EntryFilter{
|
||||||
Type: q.Get("type"),
|
DossierID: dossierID,
|
||||||
|
Type: q.Get("type"),
|
||||||
|
SearchKey: q.Get("search_key"),
|
||||||
}
|
}
|
||||||
if from := q.Get("from"); from != "" {
|
if from := q.Get("from"); from != "" {
|
||||||
filter.FromDate, _ = strconv.ParseInt(from, 10, 64)
|
filter.FromDate, _ = strconv.ParseInt(from, 10, 64)
|
||||||
|
|
@ -237,12 +239,8 @@ func v1Entries(w http.ResponseWriter, r *http.Request, dossierID string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to this dossier
|
|
||||||
var result []map[string]any
|
var result []map[string]any
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.DossierID != dossierID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry := map[string]any{
|
entry := map[string]any{
|
||||||
"id": e.EntryID,
|
"id": e.EntryID,
|
||||||
"parent_id": e.ParentID,
|
"parent_id": e.ParentID,
|
||||||
|
|
@ -305,7 +303,9 @@ func v1Entry(w http.ResponseWriter, r *http.Request, dossierID, entryID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get children
|
// Get children
|
||||||
children, _ := lib.EntryList(lib.SystemAccessorID, entryID, 0, nil) // nil ctx - v1 API has own auth
|
children, _ := lib.EntryList(lib.SystemAccessorID, entryID, 0, &lib.EntryFilter{
|
||||||
|
DossierID: e.DossierID,
|
||||||
|
}) // nil ctx - v1 API has own auth
|
||||||
if len(children) > 0 {
|
if len(children) > 0 {
|
||||||
var childList []map[string]any
|
var childList []map[string]any
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -443,18 +443,15 @@ func calculateStepSize(requestedSpacingMM, sliceThicknessMM float64) int {
|
||||||
// STUDY/SERIES/SLICE CREATION (using V2 API)
|
// STUDY/SERIES/SLICE CREATION (using V2 API)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// getOrCreateStudy finds existing study or creates new one (root-level entry)
|
// getOrCreateStudy finds existing study or creates new one (child of imaging category root)
|
||||||
func getOrCreateStudy(data []byte, dossierID string) (string, error) {
|
func getOrCreateStudy(data []byte, dossierID string) (string, error) {
|
||||||
studyUID := readStringTag(data, 0x0020, 0x000D)
|
studyUID := readStringTag(data, 0x0020, 0x000D)
|
||||||
if id, ok := studyCache[studyUID]; ok {
|
if id, ok := studyCache[studyUID]; ok {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for existing study using V2 API
|
// Query for existing study by category+type (parent-agnostic)
|
||||||
studies, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryImaging, &lib.EntryFilter{ // nil ctx - import tool
|
studies, err := lib.EntryQuery(dossierID, lib.CategoryImaging, "study")
|
||||||
DossierID: dossierID,
|
|
||||||
Type: "study",
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, s := range studies {
|
for _, s := range studies {
|
||||||
if s.Value == studyUID {
|
if s.Value == studyUID {
|
||||||
|
|
@ -464,6 +461,12 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get imaging category root (create if needed)
|
||||||
|
catRootID, err := lib.EnsureCategoryRoot(dossierID, lib.CategoryImaging)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ensure imaging category root: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract study metadata
|
// Extract study metadata
|
||||||
patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010))
|
patientName := formatPatientName(readStringTag(data, 0x0010, 0x0010))
|
||||||
studyDesc := readStringTag(data, 0x0008, 0x1030)
|
studyDesc := readStringTag(data, 0x0008, 0x1030)
|
||||||
|
|
@ -500,7 +503,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
|
||||||
e := &lib.Entry{
|
e := &lib.Entry{
|
||||||
EntryID: lib.NewID(),
|
EntryID: lib.NewID(),
|
||||||
DossierID: dossierID,
|
DossierID: dossierID,
|
||||||
ParentID: "", // root-level entry
|
ParentID: catRootID, // child of imaging category root
|
||||||
Category: lib.CategoryImaging,
|
Category: lib.CategoryImaging,
|
||||||
Type: "study",
|
Type: "study",
|
||||||
Value: studyUID,
|
Value: studyUID,
|
||||||
|
|
@ -508,7 +511,7 @@ func getOrCreateStudy(data []byte, dossierID string) (string, error) {
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
Data: string(dataJSON),
|
Data: string(dataJSON),
|
||||||
}
|
}
|
||||||
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
|
if err := lib.EntryWrite(nil, e); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
studyCache[studyUID] = e.EntryID
|
studyCache[studyUID] = e.EntryID
|
||||||
|
|
@ -573,6 +576,7 @@ func getOrCreateSeries(data []byte, dossierID, studyID string) (string, error) {
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
Tags: seriesDesc,
|
Tags: seriesDesc,
|
||||||
Data: string(dataJSON),
|
Data: string(dataJSON),
|
||||||
|
SearchKey: modality,
|
||||||
}
|
}
|
||||||
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
|
if err := lib.EntryWrite(nil, e); err != nil { // nil ctx - import tool
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
284
lib/access.go
284
lib/access.go
|
|
@ -7,24 +7,25 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RBAC Access Control - Entry-based permission system
|
// RBAC Access Control
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
//
|
//
|
||||||
// Three-level hierarchy:
|
// Grants live at three levels:
|
||||||
// 1. Root (entry_id = "") - "all" or "nothing"
|
// 1. Root (entry_id = "") — applies to all data
|
||||||
// 2. Categories - entries that are category roots (parent_id = "")
|
// 2. Category — grant on a category/category_root entry
|
||||||
// 3. Individual entries - access flows down via parent_id chain
|
// 3. Entry-specific — grant on an individual entry (rare)
|
||||||
//
|
//
|
||||||
// Operations:
|
// Operations: r=read, w=write, d=delete, m=manage
|
||||||
// r = read - view data
|
|
||||||
// w = write - create/update data
|
|
||||||
// d = delete - remove data
|
|
||||||
// m = manage - grant/revoke access to others
|
|
||||||
// "" = explicit denial (removes inherited access)
|
|
||||||
//
|
//
|
||||||
// Categories are just entries - no special handling.
|
// Resolved once per accessor+dossier (cached until permissions change):
|
||||||
// Access to parent implies access to all children.
|
// rootOps — ops from root grant
|
||||||
|
// categoryOps[cat] — ops from category-level grants
|
||||||
|
// hasChildGrants[cat] — true if entry-specific grants exist in this category
|
||||||
//
|
//
|
||||||
|
// Access check (hot path, 99% of cases = zero DB lookups):
|
||||||
|
// 1. categoryOps[cat] exists, no child grants → return it
|
||||||
|
// 2. categoryOps[cat] exists, has child grants → check entry, fall back to category
|
||||||
|
// 3. rootOps
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// AccessContext represents who is making the request
|
// AccessContext represents who is making the request
|
||||||
|
|
@ -34,78 +35,55 @@ type AccessContext struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemContext is used for internal operations that bypass RBAC
|
// SystemContext is used for internal operations that bypass RBAC
|
||||||
// Initialized in ConfigInit() with SystemAccessorID from config
|
|
||||||
var SystemContext *AccessContext
|
var SystemContext *AccessContext
|
||||||
|
|
||||||
// ErrAccessDenied is returned when permission check fails
|
|
||||||
var ErrAccessDenied = fmt.Errorf("access denied")
|
var ErrAccessDenied = fmt.Errorf("access denied")
|
||||||
|
|
||||||
// ErrNoAccessor is returned when AccessorID is empty and IsSystem is false
|
|
||||||
var ErrNoAccessor = fmt.Errorf("no accessor specified")
|
var ErrNoAccessor = fmt.Errorf("no accessor specified")
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Permission Cache
|
// Permission Cache
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type cacheEntry struct {
|
type resolvedGrants struct {
|
||||||
ops string // "r", "rw", "rwd", "rwdm"
|
rootOps string // ops for root grant (entry_id="")
|
||||||
expiresAt time.Time
|
categoryOps map[int]string // category → ops
|
||||||
|
hasChildGrants map[int]bool // category → has entry-specific grants?
|
||||||
|
entryOps map[string]string // entry_id → ops (only for rare entry-level grants)
|
||||||
}
|
}
|
||||||
|
|
||||||
type permissionCache struct {
|
type permissionCache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
cache map[string]map[string]map[string]*cacheEntry // [accessor][dossier][entry_id] -> ops
|
cache map[string]map[string]*resolvedGrants // [accessor][dossier]
|
||||||
ttl time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var permCache = &permissionCache{
|
var permCache = &permissionCache{
|
||||||
cache: make(map[string]map[string]map[string]*cacheEntry),
|
cache: make(map[string]map[string]*resolvedGrants),
|
||||||
ttl: time.Hour,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// get returns cached ops or empty string if not found/expired
|
func (c *permissionCache) get(accessorID, dossierID string) *resolvedGrants {
|
||||||
func (c *permissionCache) get(accessorID, dossierID, entryID string) string {
|
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
if c.cache[accessorID] == nil {
|
if c.cache[accessorID] == nil {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
if c.cache[accessorID][dossierID] == nil {
|
return c.cache[accessorID][dossierID]
|
||||||
return ""
|
|
||||||
}
|
|
||||||
entry := c.cache[accessorID][dossierID][entryID]
|
|
||||||
if entry == nil || time.Now().After(entry.expiresAt) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return entry.ops
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set stores ops in cache
|
func (c *permissionCache) set(accessorID, dossierID string, rg *resolvedGrants) {
|
||||||
func (c *permissionCache) set(accessorID, dossierID, entryID, ops string) {
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if c.cache[accessorID] == nil {
|
if c.cache[accessorID] == nil {
|
||||||
c.cache[accessorID] = make(map[string]map[string]*cacheEntry)
|
c.cache[accessorID] = make(map[string]*resolvedGrants)
|
||||||
}
|
|
||||||
if c.cache[accessorID][dossierID] == nil {
|
|
||||||
c.cache[accessorID][dossierID] = make(map[string]*cacheEntry)
|
|
||||||
}
|
|
||||||
c.cache[accessorID][dossierID][entryID] = &cacheEntry{
|
|
||||||
ops: ops,
|
|
||||||
expiresAt: time.Now().Add(c.ttl),
|
|
||||||
}
|
}
|
||||||
|
c.cache[accessorID][dossierID] = rg
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCacheForAccessor clears all cached permissions for an accessor
|
|
||||||
func InvalidateCacheForAccessor(accessorID string) {
|
func InvalidateCacheForAccessor(accessorID string) {
|
||||||
permCache.mu.Lock()
|
permCache.mu.Lock()
|
||||||
defer permCache.mu.Unlock()
|
defer permCache.mu.Unlock()
|
||||||
delete(permCache.cache, accessorID)
|
delete(permCache.cache, accessorID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCacheForDossier clears all cached permissions for a dossier
|
|
||||||
func InvalidateCacheForDossier(dossierID string) {
|
func InvalidateCacheForDossier(dossierID string) {
|
||||||
permCache.mu.Lock()
|
permCache.mu.Lock()
|
||||||
defer permCache.mu.Unlock()
|
defer permCache.mu.Unlock()
|
||||||
|
|
@ -114,128 +92,123 @@ func InvalidateCacheForDossier(dossierID string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCacheAll clears entire cache
|
|
||||||
func InvalidateCacheAll() {
|
func InvalidateCacheAll() {
|
||||||
permCache.mu.Lock()
|
permCache.mu.Lock()
|
||||||
defer permCache.mu.Unlock()
|
defer permCache.mu.Unlock()
|
||||||
permCache.cache = make(map[string]map[string]map[string]*cacheEntry)
|
permCache.cache = make(map[string]map[string]*resolvedGrants)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Core Permission Check (used by v2.go functions)
|
// Core Permission Check
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// checkAccess is the internal permission check called by v2.go data functions.
|
// checkAccess checks if accessor can perform op on dossier/entry.
|
||||||
// Returns nil if allowed, ErrAccessDenied if not.
|
// category: entry's category if known (0 = look up from entryID if needed)
|
||||||
//
|
func checkAccess(accessorID, dossierID, entryID string, category int, op rune) error {
|
||||||
// Parameters:
|
|
||||||
// accessorID - who is asking (empty = system/internal)
|
|
||||||
// dossierID - whose data
|
|
||||||
// entryID - specific entry (empty = root level)
|
|
||||||
// op - operation: 'r', 'w', 'd', 'm'
|
|
||||||
//
|
|
||||||
// Algorithm:
|
|
||||||
// 1. System accessor → allow (internal operations with audit trail)
|
|
||||||
// 2. Accessor == owner → allow (full access to own data)
|
|
||||||
// 3. Check grants (entry-specific → parent chain → root)
|
|
||||||
// 4. No grant → deny
|
|
||||||
func checkAccess(accessorID, dossierID, entryID string, op rune) error {
|
|
||||||
// 1. System accessor = internal operation (explicit backdoor for audit)
|
|
||||||
if accessorID == SystemAccessorID {
|
if accessorID == SystemAccessorID {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Owner has full access to own data
|
|
||||||
if accessorID == dossierID {
|
if accessorID == dossierID {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if hasOp(getEffectiveOps(accessorID, dossierID, entryID, category), op) {
|
||||||
// 3. Check grants
|
|
||||||
ops := getEffectiveOps(accessorID, dossierID, entryID)
|
|
||||||
if hasOp(ops, op) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. No grant found - deny
|
|
||||||
return ErrAccessDenied
|
return ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAccess is the exported version for use by API/Portal code.
|
// CheckAccess is the exported version (category unknown).
|
||||||
func CheckAccess(accessorID, dossierID, entryID string, op rune) error {
|
func CheckAccess(accessorID, dossierID, entryID string, op rune) error {
|
||||||
return checkAccess(accessorID, dossierID, entryID, op)
|
return checkAccess(accessorID, dossierID, entryID, 0, op)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEffectiveOps returns the ops string for accessor on dossier/entry
|
// getEffectiveOps returns ops for accessor on dossier/entry.
|
||||||
// Uses cache, falls back to database lookup
|
// category >0 avoids a DB lookup to determine the entry's category.
|
||||||
func getEffectiveOps(accessorID, dossierID, entryID string) string {
|
func getEffectiveOps(accessorID, dossierID, entryID string, category int) string {
|
||||||
// Check cache first
|
rg := resolveGrants(accessorID, dossierID)
|
||||||
if ops := permCache.get(accessorID, dossierID, entryID); ops != "" {
|
|
||||||
return ops
|
if entryID != "" {
|
||||||
|
// Determine category
|
||||||
|
cat := category
|
||||||
|
if cat == 0 {
|
||||||
|
if e, err := entryGetRaw(entryID); err == nil && e != nil {
|
||||||
|
cat = e.Category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cat > 0 {
|
||||||
|
catOps, hasCat := rg.categoryOps[cat]
|
||||||
|
|
||||||
|
// 99% path: category grant, no child grants → done
|
||||||
|
if hasCat && !rg.hasChildGrants[cat] {
|
||||||
|
return catOps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rare: entry-specific grants exist in this category
|
||||||
|
if rg.hasChildGrants[cat] {
|
||||||
|
if ops, ok := rg.entryOps[entryID]; ok {
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
// Fall back to category grant
|
||||||
|
if hasCat {
|
||||||
|
return catOps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rg.rootOps
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGrants loads grants for accessor+dossier, resolves each into
|
||||||
|
// root/category/entry buckets. Cached until permissions change.
|
||||||
|
func resolveGrants(accessorID, dossierID string) *resolvedGrants {
|
||||||
|
if rg := permCache.get(accessorID, dossierID); rg != nil {
|
||||||
|
return rg
|
||||||
|
}
|
||||||
|
|
||||||
|
rg := &resolvedGrants{
|
||||||
|
categoryOps: make(map[int]string),
|
||||||
|
hasChildGrants: make(map[int]bool),
|
||||||
|
entryOps: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load grants from database (bypasses RBAC - internal function)
|
|
||||||
grants, err := accessGrantListRaw(&PermissionFilter{
|
grants, err := accessGrantListRaw(&PermissionFilter{
|
||||||
DossierID: dossierID,
|
DossierID: dossierID,
|
||||||
GranteeID: accessorID,
|
GranteeID: accessorID,
|
||||||
})
|
})
|
||||||
if err != nil || len(grants) == 0 {
|
if err != nil || len(grants) == 0 {
|
||||||
// Cache negative result
|
permCache.set(accessorID, dossierID, rg)
|
||||||
permCache.set(accessorID, dossierID, entryID, "")
|
return rg
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find most specific matching grant
|
|
||||||
ops := findMatchingOps(grants, entryID)
|
|
||||||
permCache.set(accessorID, dossierID, entryID, ops)
|
|
||||||
return ops
|
|
||||||
}
|
|
||||||
|
|
||||||
// findMatchingOps finds the most specific grant that applies to entryID
|
|
||||||
// Priority: entry-specific > parent chain > root
|
|
||||||
// Categories are just entries - no special handling needed
|
|
||||||
func findMatchingOps(grants []*Access, entryID string) string {
|
|
||||||
// Build entry->ops map for quick lookup
|
|
||||||
grantMap := make(map[string]string) // entry_id -> ops (empty key = root)
|
|
||||||
for _, g := range grants {
|
for _, g := range grants {
|
||||||
existing := grantMap[g.EntryID]
|
if g.EntryID == "" {
|
||||||
// Merge ops (keep most permissive)
|
rg.rootOps = mergeOps(rg.rootOps, g.Ops)
|
||||||
grantMap[g.EntryID] = mergeOps(existing, g.Ops)
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Check entry-specific grant (including category entries)
|
|
||||||
if entryID != "" {
|
|
||||||
if ops, ok := grantMap[entryID]; ok {
|
|
||||||
return ops
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Walk up parent chain (using raw function to avoid RBAC recursion)
|
entry, err := entryGetRaw(g.EntryID)
|
||||||
currentID := entryID
|
if err != nil || entry == nil {
|
||||||
for i := 0; i < 100; i++ { // max depth to prevent infinite loops
|
continue
|
||||||
entry, err := entryGetRaw(currentID)
|
}
|
||||||
if err != nil || entry == nil {
|
|
||||||
break
|
if entry.Type == "category" || entry.Type == "category_root" {
|
||||||
}
|
rg.categoryOps[entry.Category] = mergeOps(rg.categoryOps[entry.Category], g.Ops)
|
||||||
if entry.ParentID == "" {
|
} else {
|
||||||
break
|
rg.entryOps[g.EntryID] = mergeOps(rg.entryOps[g.EntryID], g.Ops)
|
||||||
}
|
rg.hasChildGrants[entry.Category] = true
|
||||||
// Check parent for grant
|
|
||||||
if ops, ok := grantMap[entry.ParentID]; ok {
|
|
||||||
return ops
|
|
||||||
}
|
|
||||||
currentID = entry.ParentID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check root grant (entry_id = "" means "all")
|
permCache.set(accessorID, dossierID, rg)
|
||||||
if ops, ok := grantMap[""]; ok {
|
return rg
|
||||||
return ops
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. No grant found
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeOps combines two ops strings, keeping the most permissive
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
func mergeOps(a, b string) string {
|
func mergeOps(a, b string) string {
|
||||||
ops := make(map[rune]bool)
|
ops := make(map[rune]bool)
|
||||||
for _, c := range a {
|
for _, c := range a {
|
||||||
|
|
@ -253,7 +226,6 @@ func mergeOps(a, b string) string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasOp checks if ops string contains the requested operation
|
|
||||||
func hasOp(ops string, op rune) bool {
|
func hasOp(ops string, op rune) bool {
|
||||||
for _, c := range ops {
|
for _, c := range ops {
|
||||||
if c == op {
|
if c == op {
|
||||||
|
|
@ -263,7 +235,6 @@ func hasOp(ops string, op rune) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// accessGrantListRaw loads grants without RBAC check (for internal use by permission system)
|
|
||||||
func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
|
func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
|
||||||
q := "SELECT * FROM access WHERE 1=1"
|
q := "SELECT * FROM access WHERE 1=1"
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
|
@ -298,49 +269,41 @@ func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
|
||||||
// Utility Functions
|
// Utility Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// EnsureCategoryEntry creates a category entry if it doesn't exist
|
// EnsureCategoryRoot finds or creates the root entry for a category in a dossier.
|
||||||
// Returns the entry_id of the category entry
|
// This entry serves as parent for all entries of that category and as the
|
||||||
func EnsureCategoryEntry(dossierID string, category int) (string, error) {
|
// target for RBAC category-level grants.
|
||||||
// Check if category entry already exists (use empty string for system context)
|
func EnsureCategoryRoot(dossierID string, category int) (string, error) {
|
||||||
|
// Look for existing category_root entry
|
||||||
entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{
|
entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{
|
||||||
DossierID: dossierID,
|
DossierID: dossierID,
|
||||||
Type: "category",
|
Type: "category_root",
|
||||||
Limit: 1,
|
Limit: 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil && len(entries) > 0 {
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(entries) > 0 {
|
|
||||||
return entries[0].EntryID, nil
|
return entries[0].EntryID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create category entry
|
// Create category root entry
|
||||||
entry := &Entry{
|
entry := &Entry{
|
||||||
DossierID: dossierID,
|
DossierID: dossierID,
|
||||||
Category: category,
|
Category: category,
|
||||||
Type: "category",
|
Type: "category_root",
|
||||||
Value: CategoryName(category),
|
Value: CategoryName(category),
|
||||||
ParentID: "", // Categories are root-level
|
|
||||||
}
|
}
|
||||||
if err := EntryWrite(SystemContext, entry); err != nil {
|
if err := EntryWrite(nil, entry); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return entry.EntryID, nil
|
return entry.EntryID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAccessDossier returns true if accessor can read dossier (for quick checks)
|
|
||||||
func CanAccessDossier(accessorID, dossierID string) bool {
|
func CanAccessDossier(accessorID, dossierID string) bool {
|
||||||
return CheckAccess(accessorID, dossierID, "", 'r') == nil
|
return CheckAccess(accessorID, dossierID, "", 'r') == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanManageDossier returns true if accessor can manage permissions for dossier
|
|
||||||
func CanManageDossier(accessorID, dossierID string) bool {
|
func CanManageDossier(accessorID, dossierID string) bool {
|
||||||
return CheckAccess(accessorID, dossierID, "", 'm') == nil
|
return CheckAccess(accessorID, dossierID, "", 'm') == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GrantAccess creates an access grant
|
|
||||||
// If entryID is empty, grants root-level access
|
|
||||||
// If entryID is a category, ensures category entry exists first
|
|
||||||
func GrantAccess(dossierID, granteeID, entryID, ops string) error {
|
func GrantAccess(dossierID, granteeID, entryID, ops string) error {
|
||||||
grant := &Access{
|
grant := &Access{
|
||||||
DossierID: dossierID,
|
DossierID: dossierID,
|
||||||
|
|
@ -356,9 +319,7 @@ func GrantAccess(dossierID, granteeID, entryID, ops string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeAccess removes an access grant
|
|
||||||
func RevokeAccess(accessID string) error {
|
func RevokeAccess(accessID string) error {
|
||||||
// Get the grant to know which accessor to invalidate
|
|
||||||
var grant Access
|
var grant Access
|
||||||
if err := Load("access", accessID, &grant); err != nil {
|
if err := Load("access", accessID, &grant); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -370,8 +331,6 @@ func RevokeAccess(accessID string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessorOps returns the operations accessor can perform on dossier/entry
|
|
||||||
// Returns empty string if no access
|
|
||||||
func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string {
|
func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string {
|
||||||
if ctx == nil || ctx.AccessorID == "" {
|
if ctx == nil || ctx.AccessorID == "" {
|
||||||
if ctx != nil && ctx.IsSystem {
|
if ctx != nil && ctx.IsSystem {
|
||||||
|
|
@ -379,41 +338,32 @@ func GetAccessorOps(ctx *AccessContext, dossierID, entryID string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner has full access
|
|
||||||
if ctx.AccessorID == dossierID {
|
if ctx.AccessorID == dossierID {
|
||||||
return "rwdm"
|
return "rwdm"
|
||||||
}
|
}
|
||||||
|
return getEffectiveOps(ctx.AccessorID, dossierID, entryID, 0)
|
||||||
return getEffectiveOps(ctx.AccessorID, dossierID, entryID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DossierListAccessible returns all dossiers accessible by ctx.AccessorID
|
|
||||||
func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) {
|
func DossierListAccessible(ctx *AccessContext) ([]*Dossier, error) {
|
||||||
if ctx == nil || ctx.AccessorID == "" {
|
if ctx == nil || ctx.AccessorID == "" {
|
||||||
if ctx != nil && ctx.IsSystem {
|
if ctx != nil && ctx.IsSystem {
|
||||||
// System context: return all
|
|
||||||
return DossierList(nil, nil)
|
return DossierList(nil, nil)
|
||||||
}
|
}
|
||||||
return nil, ErrNoAccessor
|
return nil, ErrNoAccessor
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get accessor's own dossier
|
|
||||||
own, err := dossierGetRaw(ctx.AccessorID)
|
own, err := dossierGetRaw(ctx.AccessorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Invalid accessor (doesn't exist) - treat as unauthorized
|
|
||||||
return nil, ErrAccessDenied
|
return nil, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|
||||||
result := []*Dossier{own}
|
result := []*Dossier{own}
|
||||||
|
|
||||||
// Get all grants where accessor is grantee
|
|
||||||
grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID})
|
grants, err := accessGrantListRaw(&PermissionFilter{GranteeID: ctx.AccessorID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, nil // Return just own dossier on error
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect unique dossier IDs with read permission
|
|
||||||
seen := map[string]bool{ctx.AccessorID: true}
|
seen := map[string]bool{ctx.AccessorID: true}
|
||||||
for _, g := range grants {
|
for _, g := range grants {
|
||||||
if g.DossierID == "" || seen[g.DossierID] {
|
if g.DossierID == "" || seen[g.DossierID] {
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,11 @@ func Normalize(dossierID string, category int) error {
|
||||||
b, _ := json.Marshal(data)
|
b, _ := json.Marshal(data)
|
||||||
e.Data = string(b)
|
e.Data = string(b)
|
||||||
|
|
||||||
|
// Update SearchKey with LOINC code (encrypted)
|
||||||
|
if norm.Loinc != "" {
|
||||||
|
e.SearchKey = norm.Loinc
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild Summary: "Abbr: value unit"
|
// Rebuild Summary: "Abbr: value unit"
|
||||||
unit, _ := data["unit"].(string)
|
unit, _ := data["unit"].(string)
|
||||||
summary := norm.Abbr + ": " + e.Value
|
summary := norm.Abbr + ": " + e.Value
|
||||||
|
|
|
||||||
136
lib/roles.go
136
lib/roles.go
|
|
@ -24,47 +24,128 @@ type RoleTemplate struct {
|
||||||
// SystemRoles defines all available role templates
|
// SystemRoles defines all available role templates
|
||||||
var SystemRoles = []RoleTemplate{
|
var SystemRoles = []RoleTemplate{
|
||||||
{
|
{
|
||||||
Name: "Family",
|
Name: "Parent/Guardian",
|
||||||
Description: "Full access for family members",
|
Description: "Full access to child's data",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "rwdm"}, // Full access to everything
|
{Category: 0, Ops: "rwdm"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Spouse/Partner",
|
||||||
|
Description: "Full access to partner's data",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: 0, Ops: "rwdm"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Sibling",
|
||||||
|
Description: "View and add notes",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: 0, Ops: "r"},
|
||||||
|
{Category: CategoryNote, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Extended Family",
|
||||||
|
Description: "View only",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: 0, Ops: "r"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Doctor",
|
Name: "Doctor",
|
||||||
Description: "Read/write access for healthcare providers",
|
Description: "Full clinical access",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "rw"}, // Read/write to everything
|
{Category: 0, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Specialist",
|
||||||
|
Description: "Clinical data read, write imaging/labs/docs",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: 0, Ops: "r"},
|
||||||
|
{Category: CategoryImaging, Ops: "rw"},
|
||||||
|
{Category: CategoryLab, Ops: "rw"},
|
||||||
|
{Category: CategoryDocument, Ops: "rw"},
|
||||||
|
{Category: CategoryAssessment, Ops: "rw"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Caregiver",
|
Name: "Caregiver",
|
||||||
Description: "Read/write access for caregivers",
|
Description: "Daily care access",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "rw"}, // Read/write to everything
|
{Category: 0, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Physical Therapist",
|
||||||
|
Description: "Exercise, vitals, imaging",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: CategoryImaging, Ops: "r"},
|
||||||
|
{Category: CategoryLab, Ops: "r"},
|
||||||
|
{Category: CategoryVital, Ops: "rw"},
|
||||||
|
{Category: CategoryExercise, Ops: "rw"},
|
||||||
|
{Category: CategoryNote, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Nutritionist",
|
||||||
|
Description: "Diet, supplements, labs",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: CategoryLab, Ops: "r"},
|
||||||
|
{Category: CategoryVital, Ops: "r"},
|
||||||
|
{Category: CategoryNutrition, Ops: "rw"},
|
||||||
|
{Category: CategorySupplement, Ops: "rw"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Trainer",
|
Name: "Trainer",
|
||||||
Description: "Read-only with write access to exercise and nutrition",
|
Description: "Exercise, nutrition, vitals",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "r"}, // Read everything
|
{Category: CategoryVital, Ops: "r"},
|
||||||
{Category: CategoryExercise, Ops: "rw"}, // Write exercise
|
{Category: CategoryExercise, Ops: "rw"},
|
||||||
{Category: CategoryNutrition, Ops: "rw"}, // Write nutrition
|
{Category: CategoryNutrition, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Therapist",
|
||||||
|
Description: "Mental health, notes, assessments",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: CategoryLab, Ops: "r"},
|
||||||
|
{Category: CategoryVital, Ops: "r"},
|
||||||
|
{Category: CategoryNote, Ops: "rw"},
|
||||||
|
{Category: CategorySymptom, Ops: "rw"},
|
||||||
|
{Category: CategoryAssessment, Ops: "rw"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Pharmacist",
|
||||||
|
Description: "Medications, labs, genome",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: CategoryMedication, Ops: "r"},
|
||||||
|
{Category: CategoryLab, Ops: "r"},
|
||||||
|
{Category: CategoryGenome, Ops: "r"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Friend",
|
Name: "Friend",
|
||||||
Description: "Read-only access",
|
Description: "View only",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "r"}, // Read everything
|
{Category: 0, Ops: "r"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Researcher",
|
Name: "Researcher",
|
||||||
Description: "Read-only access for research purposes",
|
Description: "View only for research",
|
||||||
Grants: []RoleGrant{
|
Grants: []RoleGrant{
|
||||||
{Category: 0, Ops: "r"}, // Read everything
|
{Category: 0, Ops: "r"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Emergency",
|
||||||
|
Description: "Emergency read access",
|
||||||
|
Grants: []RoleGrant{
|
||||||
|
{Category: 0, Ops: "r"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -135,30 +216,9 @@ func ApplyRoleTemplate(dossierID, granteeID, roleName string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findOrCreateCategoryRoot finds or creates a root entry for category-level grants
|
// findOrCreateCategoryRoot is an alias for EnsureCategoryRoot (in access.go)
|
||||||
// This is a virtual entry that serves as parent for all entries of that category
|
|
||||||
func findOrCreateCategoryRoot(dossierID string, category int) (string, error) {
|
func findOrCreateCategoryRoot(dossierID string, category int) (string, error) {
|
||||||
// Look for existing category root entry (type = "category_root", use empty string for system context)
|
return EnsureCategoryRoot(dossierID, category)
|
||||||
entries, err := EntryList(SystemAccessorID, "", category, &EntryFilter{
|
|
||||||
DossierID: dossierID,
|
|
||||||
Type: "category_root",
|
|
||||||
Limit: 1,
|
|
||||||
})
|
|
||||||
if err == nil && len(entries) > 0 {
|
|
||||||
return entries[0].EntryID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create virtual category root entry
|
|
||||||
entry := &Entry{
|
|
||||||
DossierID: dossierID,
|
|
||||||
Category: category,
|
|
||||||
Type: "category_root",
|
|
||||||
Value: CategoryName(category),
|
|
||||||
}
|
|
||||||
if err := EntryWrite(nil, entry); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return entry.EntryID, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeRole removes all grants with the specified role for a grantee on a dossier
|
// RevokeRole removes all grants with the specified role for a grantee on a dossier
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ type Entry struct {
|
||||||
Status int `db:"status"`
|
Status int `db:"status"`
|
||||||
Tags string `db:"tags"`
|
Tags string `db:"tags"`
|
||||||
Data string `db:"data"`
|
Data string `db:"data"`
|
||||||
|
SearchKey string `db:"search_key"` // LOINC (labs), gene (genome), modality (imaging) - encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit represents an audit log entry
|
// Audit represents an audit log entry
|
||||||
|
|
|
||||||
73
lib/v2.go
73
lib/v2.go
|
|
@ -43,6 +43,7 @@ type EntryFilter struct {
|
||||||
DossierID string
|
DossierID string
|
||||||
Type string
|
Type string
|
||||||
Value string
|
Value string
|
||||||
|
SearchKey string
|
||||||
FromDate int64
|
FromDate int64
|
||||||
ToDate int64
|
ToDate int64
|
||||||
Limit int
|
Limit int
|
||||||
|
|
@ -60,7 +61,7 @@ func EntryWrite(ctx *AccessContext, entries ...*Entry) error {
|
||||||
return fmt.Errorf("entry missing dossier_id")
|
return fmt.Errorf("entry missing dossier_id")
|
||||||
}
|
}
|
||||||
// Check write on parent (or root if no parent)
|
// Check write on parent (or root if no parent)
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, 'w'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, e.ParentID, e.Category, 'w'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +85,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Entry doesn't exist, skip
|
continue // Entry doesn't exist, skip
|
||||||
}
|
}
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'd'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'd'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +95,7 @@ func EntryRemove(ctx *AccessContext, ids ...string) error {
|
||||||
// EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root.
|
// EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root.
|
||||||
func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
|
func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
|
||||||
// RBAC: Check delete permission on dossier root
|
// RBAC: Check delete permission on dossier root
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +119,7 @@ func EntryGet(ctx *AccessContext, id string) (*Entry, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RBAC: Check read permission
|
// RBAC: Check read permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +148,7 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dossierID != "" {
|
if dossierID != "" {
|
||||||
if err := checkAccess(accessorID, dossierID, parent, 'r'); err != nil {
|
if err := checkAccess(accessorID, dossierID, parent, category, 'r'); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +181,10 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
|
||||||
q += " AND value = ?"
|
q += " AND value = ?"
|
||||||
args = append(args, CryptoEncrypt(f.Value))
|
args = append(args, CryptoEncrypt(f.Value))
|
||||||
}
|
}
|
||||||
|
if f.SearchKey != "" {
|
||||||
|
q += " AND search_key = ?"
|
||||||
|
args = append(args, CryptoEncrypt(f.SearchKey))
|
||||||
|
}
|
||||||
if f.FromDate > 0 {
|
if f.FromDate > 0 {
|
||||||
q += " AND timestamp >= ?"
|
q += " AND timestamp >= ?"
|
||||||
args = append(args, f.FromDate)
|
args = append(args, f.FromDate)
|
||||||
|
|
@ -219,7 +224,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
|
||||||
for _, d := range dossiers {
|
for _, d := range dossiers {
|
||||||
if d.DossierID != "" {
|
if d.DossierID != "" {
|
||||||
// Update - need manage permission (unless creating own or system)
|
// Update - need manage permission (unless creating own or system)
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 'm'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), d.DossierID, "", 0, 'm'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +250,7 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
|
||||||
func DossierRemove(ctx *AccessContext, ids ...string) error {
|
func DossierRemove(ctx *AccessContext, ids ...string) error {
|
||||||
// RBAC: Check manage permission for each dossier
|
// RBAC: Check manage permission for each dossier
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), id, "", 'm'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'm'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +260,7 @@ func DossierRemove(ctx *AccessContext, ids ...string) error {
|
||||||
// DossierGet retrieves a dossier. Requires read permission.
|
// DossierGet retrieves a dossier. Requires read permission.
|
||||||
func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
|
func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
|
||||||
// RBAC: Check read permission
|
// RBAC: Check read permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), id, "", 'r'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), id, "", 0, 'r'); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,7 +557,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RBAC: Check read permission
|
// RBAC: Check read permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, 'r'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), e.DossierID, id, e.Category, 'r'); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -637,7 +642,7 @@ func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
|
||||||
// ObjectWrite encrypts and writes data to the object store. Requires write permission.
|
// ObjectWrite encrypts and writes data to the object store. Requires write permission.
|
||||||
func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error {
|
func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error {
|
||||||
// RBAC: Check write permission
|
// RBAC: Check write permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'w'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'w'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -652,7 +657,7 @@ func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) err
|
||||||
// ObjectRead reads and decrypts data from the object store. Requires read permission.
|
// ObjectRead reads and decrypts data from the object store. Requires read permission.
|
||||||
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
|
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
|
||||||
// RBAC: Check read permission
|
// RBAC: Check read permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'r'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'r'); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -675,7 +680,7 @@ func objectReadRaw(dossierID, entryID string) ([]byte, error) {
|
||||||
// ObjectRemove deletes an object from the store. Requires delete permission.
|
// ObjectRemove deletes an object from the store. Requires delete permission.
|
||||||
func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
|
func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
|
||||||
// RBAC: Check delete permission
|
// RBAC: Check delete permission
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 'd'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'd'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.Remove(ObjectPath(dossierID, entryID))
|
return os.Remove(ObjectPath(dossierID, entryID))
|
||||||
|
|
@ -684,7 +689,7 @@ func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
|
||||||
// ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission.
|
// ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission.
|
||||||
func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error {
|
func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error {
|
||||||
// RBAC: Check delete permission on dossier root
|
// RBAC: Check delete permission on dossier root
|
||||||
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 'd'); err != nil {
|
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'd'); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.RemoveAll(filepath.Join(ObjectDir, dossierID))
|
return os.RemoveAll(filepath.Join(ObjectDir, dossierID))
|
||||||
|
|
@ -841,6 +846,48 @@ func MigrateOldAccess() int {
|
||||||
return migrated
|
return migrated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrateStudiesToCategoryRoot moves orphan studies (parent_id="") under their
|
||||||
|
// imaging category_root entry. Idempotent — skips studies already parented.
|
||||||
|
func MigrateStudiesToCategoryRoot() int {
|
||||||
|
// Find all imaging entries with empty parent_id, filter to studies in Go
|
||||||
|
var all []*Entry
|
||||||
|
err := Query(
|
||||||
|
"SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')",
|
||||||
|
[]any{CategoryImaging}, &all)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var studies []*Entry
|
||||||
|
for _, e := range all {
|
||||||
|
if e.Type == "study" {
|
||||||
|
studies = append(studies, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(studies) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated := 0
|
||||||
|
catRoots := map[string]string{} // dossier_id → category_root entry_id
|
||||||
|
|
||||||
|
for _, s := range studies {
|
||||||
|
rootID, ok := catRoots[s.DossierID]
|
||||||
|
if !ok {
|
||||||
|
rootID, err = EnsureCategoryRoot(s.DossierID, CategoryImaging)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catRoots[s.DossierID] = rootID
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ParentID = rootID
|
||||||
|
if err := Save("entries", s); err == nil {
|
||||||
|
migrated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
// AccessGrantGet retrieves a single access grant by ID
|
// AccessGrantGet retrieves a single access grant by ID
|
||||||
func AccessGrantGet(id string) (*Access, error) {
|
func AccessGrantGet(id string) (*Access, error) {
|
||||||
a := &Access{}
|
a := &Access{}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
-- ============================================================================
|
||||||
|
|
@ -57,6 +57,7 @@ var validPaths = []string{
|
||||||
"/privacy-policy",
|
"/privacy-policy",
|
||||||
"/security",
|
"/security",
|
||||||
"/legal/dpa",
|
"/legal/dpa",
|
||||||
|
"/legal/terms",
|
||||||
"/demo",
|
"/demo",
|
||||||
"/oauth/authorize",
|
"/oauth/authorize",
|
||||||
"/oauth/token",
|
"/oauth/token",
|
||||||
|
|
|
||||||
144
portal/main.go
144
portal/main.go
|
|
@ -131,6 +131,7 @@ type PageData struct {
|
||||||
EntryGrants []EntryGrant
|
EntryGrants []EntryGrant
|
||||||
// RBAC edit page
|
// RBAC edit page
|
||||||
CategoriesRBAC []CategoryRBACView
|
CategoriesRBAC []CategoryRBACView
|
||||||
|
SelectedRole string
|
||||||
// Dossier: unified sections
|
// Dossier: unified sections
|
||||||
Sections []DossierSection
|
Sections []DossierSection
|
||||||
LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh}
|
LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh}
|
||||||
|
|
@ -738,6 +739,11 @@ func handleDPA(w http.ResponseWriter, r *http.Request) {
|
||||||
p := getLoggedInDossier(r)
|
p := getLoggedInDossier(r)
|
||||||
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p})
|
render(w, r, PageData{Page: "dpa", Lang: getLang(r), Dossier: p})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleTerms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := getLoggedInDossier(r)
|
||||||
|
render(w, r, PageData{Page: "terms", Lang: getLang(r), Dossier: p})
|
||||||
|
}
|
||||||
func handleStyleguide(w http.ResponseWriter, r *http.Request) {
|
func handleStyleguide(w http.ResponseWriter, r *http.Request) {
|
||||||
p := getLoggedInDossier(r)
|
p := getLoggedInDossier(r)
|
||||||
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
|
render(w, r, PageData{Page: "styleguide", Lang: getLang(r), Embed: isEmbed(r), Dossier: p})
|
||||||
|
|
@ -1435,13 +1441,12 @@ type RoleView struct {
|
||||||
|
|
||||||
// CategoryRBACView represents a category with per-operation permissions
|
// CategoryRBACView represents a category with per-operation permissions
|
||||||
type CategoryRBACView struct {
|
type CategoryRBACView struct {
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
Description string
|
CanRead bool
|
||||||
CanRead bool
|
CanWrite bool
|
||||||
CanWrite bool
|
CanDelete bool
|
||||||
CanDelete bool
|
CanManage bool
|
||||||
CanManage bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePermissions(w http.ResponseWriter, r *http.Request) {
|
func handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -1643,35 +1648,18 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if action == "update" {
|
if action == "update" {
|
||||||
// Build base ops from checkboxes
|
roleName := r.FormValue("role")
|
||||||
baseOps := ""
|
if roleName == "" { roleName = "Custom" }
|
||||||
if r.FormValue("op_r") == "1" { baseOps += "r" }
|
|
||||||
if r.FormValue("op_w") == "1" { baseOps += "w" }
|
|
||||||
if r.FormValue("op_d") == "1" { baseOps += "d" }
|
|
||||||
if r.FormValue("op_m") == "1" { baseOps += "m" }
|
|
||||||
|
|
||||||
// Clear existing grants
|
// Clear existing grants
|
||||||
lib.AccessRevokeAll(targetID, granteeID)
|
lib.AccessRevokeAll(targetID, granteeID)
|
||||||
|
|
||||||
// Create root grant if base ops specified
|
// Create per-category grants (all categories except All=0 and Upload=5)
|
||||||
if baseOps != "" {
|
for _, cat := range lib.Categories() {
|
||||||
lib.AccessGrantWrite(&lib.Access{
|
if cat.ID == lib.CategoryUpload {
|
||||||
DossierID: targetID,
|
continue
|
||||||
GranteeID: granteeID,
|
}
|
||||||
EntryID: "",
|
catID := cat.ID
|
||||||
Role: "Custom",
|
|
||||||
Ops: baseOps,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create category-specific grants
|
|
||||||
allCats := []int{
|
|
||||||
lib.CategoryImaging, lib.CategoryDocument, lib.CategoryLab,
|
|
||||||
lib.CategoryGenome, lib.CategoryVital, lib.CategoryMedication,
|
|
||||||
lib.CategorySupplement, lib.CategoryExercise, lib.CategorySymptom,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, catID := range allCats {
|
|
||||||
catOps := ""
|
catOps := ""
|
||||||
if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" }
|
if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" }
|
||||||
if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" }
|
if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" }
|
||||||
|
|
@ -1679,14 +1667,13 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" }
|
if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" }
|
||||||
|
|
||||||
if catOps != "" {
|
if catOps != "" {
|
||||||
// Ensure category entry exists
|
entryID, err := lib.EnsureCategoryRoot(targetID, catID)
|
||||||
entryID, err := lib.EnsureCategoryEntry(targetID, catID)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lib.AccessGrantWrite(&lib.Access{
|
lib.AccessGrantWrite(&lib.Access{
|
||||||
DossierID: targetID,
|
DossierID: targetID,
|
||||||
GranteeID: granteeID,
|
GranteeID: granteeID,
|
||||||
EntryID: entryID,
|
EntryID: entryID,
|
||||||
Role: "Custom",
|
Role: roleName,
|
||||||
Ops: catOps,
|
Ops: catOps,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1694,7 +1681,7 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
lib.InvalidateCacheForAccessor(granteeID)
|
lib.InvalidateCacheForAccessor(granteeID)
|
||||||
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", baseOps, 0)
|
lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", "", 0)
|
||||||
http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1703,63 +1690,43 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||||||
// GET: Load current grants and build view
|
// GET: Load current grants and build view
|
||||||
grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID})
|
grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID})
|
||||||
|
|
||||||
// Parse grants to determine permissions
|
// Parse grants to determine per-category permissions and detect role
|
||||||
hasRead, hasWrite, hasDelete, hasManage := false, false, false, false
|
|
||||||
catPerms := make(map[int]map[rune]bool) // catID -> op -> bool
|
catPerms := make(map[int]map[rune]bool) // catID -> op -> bool
|
||||||
|
selectedRole := "Custom"
|
||||||
for _, g := range grants {
|
for _, g := range grants {
|
||||||
|
if g.Role != "" && selectedRole == "Custom" {
|
||||||
|
selectedRole = g.Role
|
||||||
|
} else if g.Role != "" && g.Role != selectedRole {
|
||||||
|
selectedRole = "Custom"
|
||||||
|
}
|
||||||
if g.EntryID == "" {
|
if g.EntryID == "" {
|
||||||
// Root grant - applies to base permissions
|
continue // Root grants not shown in per-category view
|
||||||
for _, op := range g.Ops {
|
}
|
||||||
switch op {
|
entry, err := lib.EntryGet(nil, g.EntryID)
|
||||||
case 'r': hasRead = true
|
if err == nil && entry != nil && (entry.Type == "category" || entry.Type == "category_root") {
|
||||||
case 'w': hasWrite = true
|
if catPerms[entry.Category] == nil {
|
||||||
case 'd': hasDelete = true
|
catPerms[entry.Category] = make(map[rune]bool)
|
||||||
case 'm': hasManage = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
for _, op := range g.Ops {
|
||||||
// Entry-specific grant - find which category
|
catPerms[entry.Category][op] = true
|
||||||
entry, err := lib.EntryGet(nil, g.EntryID)
|
|
||||||
if err == nil && entry != nil && entry.Type == "category" {
|
|
||||||
if catPerms[entry.Category] == nil {
|
|
||||||
catPerms[entry.Category] = make(map[rune]bool)
|
|
||||||
}
|
|
||||||
for _, op := range g.Ops {
|
|
||||||
catPerms[entry.Category][op] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build category RBAC views
|
// Build category RBAC views (all categories except All=0 and Upload=5)
|
||||||
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"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var categoriesRBAC []CategoryRBACView
|
var categoriesRBAC []CategoryRBACView
|
||||||
for _, def := range categoryDefs {
|
for _, cat := range lib.Categories() {
|
||||||
perms := catPerms[def.ID]
|
if cat.ID == lib.CategoryUpload {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perms := catPerms[cat.ID]
|
||||||
categoriesRBAC = append(categoriesRBAC, CategoryRBACView{
|
categoriesRBAC = append(categoriesRBAC, CategoryRBACView{
|
||||||
ID: def.ID,
|
ID: cat.ID,
|
||||||
Name: def.Name,
|
Name: cat.Name,
|
||||||
Description: def.Desc,
|
CanRead: perms['r'],
|
||||||
CanRead: perms['r'],
|
CanWrite: perms['w'],
|
||||||
CanWrite: perms['w'],
|
CanDelete: perms['d'],
|
||||||
CanDelete: perms['d'],
|
CanManage: perms['m'],
|
||||||
CanManage: perms['m'],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1787,12 +1754,9 @@ func handleEditRBAC(w http.ResponseWriter, r *http.Request) {
|
||||||
TargetDossier: target,
|
TargetDossier: target,
|
||||||
GranteeID: granteeID,
|
GranteeID: granteeID,
|
||||||
GranteeName: grantee.Name,
|
GranteeName: grantee.Name,
|
||||||
HasRead: hasRead,
|
|
||||||
HasWrite: hasWrite,
|
|
||||||
HasDelete: hasDelete,
|
|
||||||
HasManage: hasManage,
|
|
||||||
CategoriesRBAC: categoriesRBAC,
|
CategoriesRBAC: categoriesRBAC,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
|
SelectedRole: selectedRole,
|
||||||
Success: successMsg,
|
Success: successMsg,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2026,6 +1990,8 @@ func setupMux() http.Handler {
|
||||||
mux.HandleFunc("/security", handleSecurity)
|
mux.HandleFunc("/security", handleSecurity)
|
||||||
mux.HandleFunc("/legal/dpa", handleDPA)
|
mux.HandleFunc("/legal/dpa", handleDPA)
|
||||||
mux.HandleFunc("/legal/dpa/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/dpa", http.StatusMovedPermanently) })
|
mux.HandleFunc("/legal/dpa/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/dpa", http.StatusMovedPermanently) })
|
||||||
|
mux.HandleFunc("/legal/terms", handleTerms)
|
||||||
|
mux.HandleFunc("/legal/terms/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/legal/terms", http.StatusMovedPermanently) })
|
||||||
mux.HandleFunc("/styleguide", handleStyleguide)
|
mux.HandleFunc("/styleguide", handleStyleguide)
|
||||||
mux.HandleFunc("/demo", handleDemo)
|
mux.HandleFunc("/demo", handleDemo)
|
||||||
mux.HandleFunc("/dossier/add", handleAddDossier)
|
mux.HandleFunc("/dossier/add", handleAddDossier)
|
||||||
|
|
@ -2103,6 +2069,10 @@ func main() {
|
||||||
if n := lib.MigrateOldAccess(); n > 0 {
|
if n := lib.MigrateOldAccess(); n > 0 {
|
||||||
fmt.Printf("Migrated %d access grants from dossier_access\n", n)
|
fmt.Printf("Migrated %d access grants from dossier_access\n", n)
|
||||||
}
|
}
|
||||||
|
// Migrate orphan studies to imaging category root (idempotent)
|
||||||
|
if n := lib.MigrateStudiesToCategoryRoot(); n > 0 {
|
||||||
|
fmt.Printf("Migrated %d studies to imaging category root\n", n)
|
||||||
|
}
|
||||||
|
|
||||||
loadTranslations()
|
loadTranslations()
|
||||||
lib.TranslateInit("lang") // also init lib translations for CategoryTranslate
|
lib.TranslateInit("lang") // also init lib translations for CategoryTranslate
|
||||||
|
|
|
||||||
|
|
@ -378,17 +378,18 @@ func handleMCPToolsList(w http.ResponseWriter, req mcpRequest) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "query_entries",
|
"name": "query_entries",
|
||||||
"description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: LOINC codes (e.g., '2947-0') provide best accuracy. Use get_categories first to discover available categories.",
|
"description": "Query entries for any category (labs, documents, etc.). For imaging, use list_studies/list_series/list_slices. For genome, use query_genome. For labs: Use search_key with LOINC code (e.g., '2947-0') for fast, accurate results. Use get_categories first to discover available categories.",
|
||||||
"inputSchema": map[string]interface{}{
|
"inputSchema": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
"dossier": map[string]interface{}{"type": "string", "description": "Dossier ID (16-char hex)"},
|
||||||
"category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"},
|
"category": map[string]interface{}{"type": "string", "description": "Category: 'labs', 'documents', etc. (use get_categories to list)"},
|
||||||
"type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., LOINC code for labs)"},
|
"type": map[string]interface{}{"type": "string", "description": "Type within category (e.g., test name for labs)"},
|
||||||
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"},
|
"search_key": map[string]interface{}{"type": "string", "description": "LOINC code for labs (e.g., '2947-0'), gene name for genome (e.g., 'MTHFR')"},
|
||||||
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
|
"parent": map[string]interface{}{"type": "string", "description": "Parent entry ID for hierarchical queries"},
|
||||||
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
|
"from": map[string]interface{}{"type": "string", "description": "Timestamp start (Unix seconds)"},
|
||||||
"limit": map[string]interface{}{"type": "number", "description": "Maximum results"},
|
"to": map[string]interface{}{"type": "string", "description": "Timestamp end (Unix seconds)"},
|
||||||
|
"limit": map[string]interface{}{"type": "number", "description": "Maximum results"},
|
||||||
},
|
},
|
||||||
"required": []string{"dossier"},
|
"required": []string{"dossier"},
|
||||||
},
|
},
|
||||||
|
|
@ -526,11 +527,12 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
|
||||||
}
|
}
|
||||||
category, _ := params.Arguments["category"].(string)
|
category, _ := params.Arguments["category"].(string)
|
||||||
typ, _ := params.Arguments["type"].(string)
|
typ, _ := params.Arguments["type"].(string)
|
||||||
|
searchKey, _ := params.Arguments["search_key"].(string)
|
||||||
parent, _ := params.Arguments["parent"].(string)
|
parent, _ := params.Arguments["parent"].(string)
|
||||||
from, _ := params.Arguments["from"].(string)
|
from, _ := params.Arguments["from"].(string)
|
||||||
to, _ := params.Arguments["to"].(string)
|
to, _ := params.Arguments["to"].(string)
|
||||||
limit, _ := params.Arguments["limit"].(float64)
|
limit, _ := params.Arguments["limit"].(float64)
|
||||||
result, err := mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to, int(limit))
|
result, err := mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to, int(limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendMCPError(w, req.ID, -32000, err.Error())
|
sendMCPError(w, req.ID, -32000, err.Error())
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ func mcpFetchContactSheet(accessToken, dossier, series string, wc, ww float64) (
|
||||||
return mcpImageContent(b64, "image/webp", fmt.Sprintf("Contact sheet %s (%d bytes)", series[:8], len(body))), nil
|
return mcpImageContent(b64, "image/webp", fmt.Sprintf("Contact sheet %s (%d bytes)", series[:8], len(body))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to string, limit int) (string, error) {
|
func mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, from, to string, limit int) (string, error) {
|
||||||
params := map[string]string{}
|
params := map[string]string{}
|
||||||
if category != "" {
|
if category != "" {
|
||||||
params["category"] = category
|
params["category"] = category
|
||||||
|
|
@ -151,6 +151,9 @@ func mcpQueryEntries(accessToken, dossier, category, typ, parent, from, to strin
|
||||||
if typ != "" {
|
if typ != "" {
|
||||||
params["type"] = typ
|
params["type"] = typ
|
||||||
}
|
}
|
||||||
|
if searchKey != "" {
|
||||||
|
params["search_key"] = searchKey
|
||||||
|
}
|
||||||
if parent != "" {
|
if parent != "" {
|
||||||
params["parent"] = parent
|
params["parent"] = parent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,26 @@ func handlePrompts(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&apiPrompts)
|
json.NewDecoder(resp.Body).Decode(&apiPrompts)
|
||||||
|
|
||||||
|
// Helper to translate category name
|
||||||
|
translateCategory := func(cat string) string {
|
||||||
|
switch cat {
|
||||||
|
case "supplement": return T(lang, "section_supplements")
|
||||||
|
case "medication": return T(lang, "section_medications")
|
||||||
|
case "vital": return T(lang, "section_vitals")
|
||||||
|
case "exercise": return T(lang, "section_exercise")
|
||||||
|
case "symptom": return T(lang, "section_symptoms")
|
||||||
|
case "nutrition": return T(lang, "section_nutrition")
|
||||||
|
case "note": return T(lang, "section_notes")
|
||||||
|
default: return cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to view models
|
// Convert to view models
|
||||||
var prompts []PromptView
|
var prompts []PromptView
|
||||||
for _, ap := range apiPrompts {
|
for _, ap := range apiPrompts {
|
||||||
pv := PromptView{
|
pv := PromptView{
|
||||||
ID: ap.ID,
|
ID: ap.ID,
|
||||||
Category: ap.Category,
|
Category: translateCategory(ap.Category),
|
||||||
Type: ap.Type,
|
Type: ap.Type,
|
||||||
Question: ap.Question,
|
Question: ap.Question,
|
||||||
NextAsk: ap.NextAsk,
|
NextAsk: ap.NextAsk,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
<a href="/privacy-policy">Privacy Policy</a>
|
<a href="/privacy-policy">Privacy Policy</a>
|
||||||
<a href="/security">Security</a>
|
<a href="/security">Security</a>
|
||||||
<a href="/legal/dpa">DPA</a>
|
<a href="/legal/dpa">DPA</a>
|
||||||
|
<a href="/legal/terms">Terms</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if .Dossier}}<a href="/connect" class="nav-link">Connect</a>{{end}}
|
{{if .Dossier}}<a href="/connect" class="nav-link">Connect</a>{{end}}
|
||||||
|
|
@ -104,6 +105,7 @@
|
||||||
{{else if eq .Page "privacy"}}{{template "privacy" .}}
|
{{else if eq .Page "privacy"}}{{template "privacy" .}}
|
||||||
{{else if eq .Page "security"}}{{template "security" .}}
|
{{else if eq .Page "security"}}{{template "security" .}}
|
||||||
{{else if eq .Page "dpa"}}{{template "dpa" .}}
|
{{else if eq .Page "dpa"}}{{template "dpa" .}}
|
||||||
|
{{else if eq .Page "terms"}}{{template "terms" .}}
|
||||||
{{else if eq .Page "styleguide"}}{{template "styleguide" .}}
|
{{else if eq .Page "styleguide"}}{{template "styleguide" .}}
|
||||||
{{else if eq .Page "pricing"}}{{template "pricing" .}}
|
{{else if eq .Page "pricing"}}{{template "pricing" .}}
|
||||||
{{else if eq .Page "faq"}}{{template "faq" .}}
|
{{else if eq .Page "faq"}}{{template "faq" .}}
|
||||||
|
|
|
||||||
|
|
@ -316,20 +316,31 @@ function renderFilterChart(card, table, q) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate global time range across all charts for alignment
|
||||||
|
let globalTMin = Infinity, globalTMax = -Infinity;
|
||||||
|
for (const [, s] of chartable) {
|
||||||
|
s.points.sort((a, b) => a.date - b.date);
|
||||||
|
if (s.points.length > 0) {
|
||||||
|
globalTMin = Math.min(globalTMin, s.points[0].date.getTime());
|
||||||
|
globalTMax = Math.max(globalTMax, s.points[s.points.length - 1].date.getTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extend to today if last point is in the past
|
||||||
|
globalTMax = Math.max(globalTMax, new Date().getTime());
|
||||||
|
|
||||||
wrapper.style.display = '';
|
wrapper.style.display = '';
|
||||||
wrapper.classList.remove('collapsed');
|
wrapper.classList.remove('collapsed');
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const [loinc, s] of chartable) {
|
for (const [loinc, s] of chartable) {
|
||||||
s.points.sort((a, b) => a.date - b.date);
|
|
||||||
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
|
// Build display name: "Full Name (Abbr)" or fallback to abbreviation
|
||||||
const fullName = loincNames[loinc] || s.abbr;
|
const fullName = loincNames[loinc] || s.abbr;
|
||||||
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr;
|
const displayName = fullName !== s.abbr ? `${fullName} (${s.abbr})` : s.abbr;
|
||||||
html += buildSVGChart(displayName, s.unit, s.points, s.abbr);
|
html += buildSVGChart(displayName, s.unit, s.points, s.abbr, globalTMin, globalTMax);
|
||||||
}
|
}
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSVGChart(name, unit, points, abbr) {
|
function buildSVGChart(name, unit, points, abbr, globalTMin, globalTMax) {
|
||||||
const W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 };
|
const W = 1200, H = 200, PAD = { top: 30, right: 30, bottom: 35, left: 55 };
|
||||||
const pw = W - PAD.left - PAD.right;
|
const pw = W - PAD.left - PAD.right;
|
||||||
const ph = H - PAD.top - PAD.bottom;
|
const ph = H - PAD.top - PAD.bottom;
|
||||||
|
|
@ -349,7 +360,9 @@ function buildSVGChart(name, unit, points, abbr) {
|
||||||
// Never show negative Y when all values are >= 0
|
// Never show negative Y when all values are >= 0
|
||||||
if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin);
|
if (Math.min(...vals) >= 0 && (!ref || ref.refLow >= 0)) yMin = Math.max(0, yMin);
|
||||||
|
|
||||||
const tMin = points[0].date.getTime(), tMax = Math.max(new Date().getTime(), points[points.length-1].date.getTime());
|
// Use global time range if provided, otherwise fall back to local range
|
||||||
|
const tMin = globalTMin !== undefined ? globalTMin : points[0].date.getTime();
|
||||||
|
const tMax = globalTMax !== undefined ? globalTMax : Math.max(new Date().getTime(), points[points.length-1].date.getTime());
|
||||||
const tRange = tMax - tMin || 1;
|
const tRange = tMax - tMin || 1;
|
||||||
|
|
||||||
const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw;
|
const x = p => PAD.left + ((p.date.getTime() - tMin) / tRange) * pw;
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
<h2>Data we process</h2>
|
<h2>Data we process</h2>
|
||||||
|
|
||||||
<h3>Health data.</h3>
|
<h3>Health data.</h3>
|
||||||
<p>Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload.</p>
|
<p>Medical imaging (DICOM files including MRI, CT, X-ray), laboratory results, genetic/genomic data, and any other health information you upload. Genetic and genomic data constitutes special category data under GDPR Article 9 and is processed solely on the basis of your explicit consent.</p>
|
||||||
|
|
||||||
<h3>Account data.</h3>
|
<h3>Account data.</h3>
|
||||||
<p>Name, email address, date of birth, and sex. Used for account management and medical context.</p>
|
<p>Name, email address, date of birth, and sex. Used for account management and medical context.</p>
|
||||||
|
|
@ -238,11 +238,12 @@
|
||||||
|
|
||||||
<div class="dpa-card">
|
<div class="dpa-card">
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
|
<p>Data Protection Officer: Johan Jongsma</p>
|
||||||
<p>Questions about data processing: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
|
<p>Questions about data processing: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
|
||||||
<p>This agreement was last updated on January 21, 2026.</p>
|
<p>This agreement was last updated on February 8, 2026.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "footer"}}
|
{{template "footer"}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{{define "edit_rbac"}}
|
{{define "edit_rbac"}}
|
||||||
<div class="sg-container" style="justify-content: center;">
|
<div class="sg-container" style="justify-content: center;">
|
||||||
<div style="flex: 1; display: flex; align-items: flex-start; padding-top: 5vh; justify-content: center;">
|
<div style="flex: 1; display: flex; align-items: flex-start; padding-top: 16px; justify-content: center;">
|
||||||
<div class="data-card" style="padding: 48px; max-width: 800px; width: 100%;">
|
<div class="data-card" style="padding: 48px; max-width: 780px; width: 100%;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">Edit permissions</h1>
|
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">Edit permissions</h1>
|
||||||
|
|
@ -15,95 +15,54 @@
|
||||||
|
|
||||||
<form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST">
|
<form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST">
|
||||||
<input type="hidden" name="action" value="update">
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="hidden" name="role" id="roleHidden" value="{{.SelectedRole}}">
|
||||||
|
|
||||||
<!-- Role Selector -->
|
<!-- Role Selector -->
|
||||||
<div style="margin-bottom: 32px;">
|
<div style="margin-bottom: 24px;">
|
||||||
<div class="form-group">
|
<label style="font-weight: 600; margin-bottom: 8px; display: block;">Role</label>
|
||||||
<label style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Role Template</label>
|
<select id="roleSelect" class="sg-select" style="width: 100%;">
|
||||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 12px;">Quick presets for common access patterns</p>
|
<option value="Custom"{{if eq .SelectedRole "Custom"}} selected{{end}}>Custom</option>
|
||||||
<select id="roleSelect" class="sg-select" style="width: 100%;">
|
{{range .Roles}}
|
||||||
<option value="">Custom (manual selection)</option>
|
<option value="{{.Name}}" data-grants='{{.GrantsJSON}}'{{if eq $.SelectedRole .Name}} selected{{end}}>{{.Name}} — {{.Description}}</option>
|
||||||
{{range .Roles}}
|
|
||||||
<option value="{{.Name}}" data-grants='{{.GrantsJSON}}'>{{.Name}} — {{.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>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 12px; margin-top: 24px;">
|
<!-- 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}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-top: 32px;">
|
||||||
<a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary" style="flex: 1; text-align: center;">Cancel</a>
|
<a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary" style="flex: 1; text-align: center;">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button>
|
<button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,49 +79,106 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.col-toggle { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('roleSelect').addEventListener('change', function() {
|
(function() {
|
||||||
if (!this.value) return;
|
const roleSelect = document.getElementById('roleSelect');
|
||||||
|
const roleHidden = document.getElementById('roleHidden');
|
||||||
|
const allCbs = document.querySelectorAll('.perm-cb');
|
||||||
|
|
||||||
const grantsJSON = this.options[this.selectedOptions[0]].dataset.grants;
|
const catIDs = [];
|
||||||
if (!grantsJSON) return;
|
document.querySelectorAll('tr[data-cat]').forEach(tr => {
|
||||||
|
catIDs.push(parseInt(tr.dataset.cat));
|
||||||
const grants = JSON.parse(grantsJSON);
|
|
||||||
|
|
||||||
// Clear all checkboxes first
|
|
||||||
document.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
|
|
||||||
|
|
||||||
// Apply grants
|
|
||||||
grants.forEach(grant => {
|
|
||||||
const ops = grant.Ops || '';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
|
allCbs.forEach(cb => cb.checked = false);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<div class="sg-footer-left">
|
<div class="sg-footer-left">
|
||||||
<span>© 2026</span>
|
<span>© 2026</span>
|
||||||
<a href="/privacy-policy">Privacy</a>
|
<a href="/privacy-policy">Privacy</a>
|
||||||
|
<a href="/legal/terms">Terms</a>
|
||||||
<a href="/pricing">Pricing</a>
|
<a href="/pricing">Pricing</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="sg-footer-right"><span class="inou">inou</span> <span class="health">health</span></span>
|
<span class="sg-footer-right"><span class="inou">inou</span> <span class="health">health</span></span>
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,12 @@
|
||||||
<p>Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.</p>
|
<p>Your data is used solely to store and display your medical information. We do not perform AI analysis — you connect your own AI tools to access your data. We do not use your data to train AI models or for any purpose beyond providing the service.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="privacy-card">
|
||||||
|
<h2>Legal basis for processing</h2>
|
||||||
|
<p>We process your data based on your explicit consent, given when you create your account and upload health information. For account management and security (such as login sessions and IP logging), we rely on legitimate interest in operating a secure service. You may withdraw consent at any time by deleting your account — we will stop all processing immediately.</p>
|
||||||
|
<p>Genetic and genomic data is classified as special category data under GDPR Article 9. By uploading genetic data to <span class="inou-brand">inou</span>, you provide explicit consent for us to store and display it. We process this data solely to show it back to you and to transmit it to services you authorize. We do not analyze, profile, or make decisions based on your genetic information.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="privacy-card">
|
<div class="privacy-card">
|
||||||
<h2>What we promise</h2>
|
<h2>What we promise</h2>
|
||||||
|
|
||||||
|
|
@ -192,7 +198,7 @@
|
||||||
<p>Found a mistake? You can correct it yourself, or ask us to help.</p>
|
<p>Found a mistake? You can correct it yourself, or ask us to help.</p>
|
||||||
|
|
||||||
<h3>Delete everything.</h3>
|
<h3>Delete everything.</h3>
|
||||||
<p>One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster — we do not offer restores of individual accounts or deleted data.</p>
|
<p>One click. All your data — files, metadata, everything — permanently destroyed. No questions, no delays, no recovery. Backups exist solely to protect the service as a whole in case of disaster. Backup copies are overwritten within 30 days of deletion. We do not offer restores of individual accounts or deleted data.</p>
|
||||||
|
|
||||||
<h3>Take it with you.</h3>
|
<h3>Take it with you.</h3>
|
||||||
<p>Want to move to another service? We'll export your data in standard formats. You're never locked in.</p>
|
<p>Want to move to another service? We'll export your data in standard formats. You're never locked in.</p>
|
||||||
|
|
@ -213,6 +219,11 @@
|
||||||
<p>We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.</p>
|
<p>We chose this architecture so your data is never copied, never stored by the AI, and never used for training — but ultimately, your choice of AI is your choice.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="privacy-card">
|
||||||
|
<h2>Not a medical device</h2>
|
||||||
|
<p><span class="inou-brand">inou</span> is a personal health data viewer. It is not a medical device and is not intended for clinical diagnosis, treatment, cure, or prevention of any disease or medical condition. The platform stores and displays your health data — it does not analyze, interpret, or act on it. Always consult a qualified healthcare professional for medical decisions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="privacy-card">
|
<div class="privacy-card">
|
||||||
<h2>Children's privacy</h2>
|
<h2>Children's privacy</h2>
|
||||||
<p><span class="inou-brand">inou</span> is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.</p>
|
<p><span class="inou-brand">inou</span> is not available to users under 18 years of age — unless authorized by a parent or guardian. Minors cannot create accounts independently. A parent or guardian must set up access and remains responsible for the account. Parents or guardians retain full control and can revoke access at any time. Minors cannot share their information with third parties.</p>
|
||||||
|
|
@ -223,10 +234,11 @@
|
||||||
<p>We comply with <strong>FADP</strong> (Swiss data protection), <strong>GDPR</strong> (European data protection), and <strong>HIPAA</strong> (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.</p>
|
<p>We comply with <strong>FADP</strong> (Swiss data protection), <strong>GDPR</strong> (European data protection), and <strong>HIPAA</strong> (US medical privacy) standards. Regardless of where you live, you get our highest level of protection.</p>
|
||||||
<p>We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p>
|
<p>We may update this policy. Registered users will be notified by email of material changes. Continued use after changes constitutes acceptance.</p>
|
||||||
<p>Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.</p>
|
<p>Regardless of your jurisdiction, you may request access to your data, correction of inaccuracies, or complete deletion of your account. We will respond within 30 days.</p>
|
||||||
<p>Questions, concerns, or requests: <a href="mailto:privacy@inou.com">privacy@inou.com</a></p>
|
<p>Our Data Protection Officer is Johan Jongsma. For all privacy and data protection inquiries, contact <a href="mailto:privacy@inou.com">privacy@inou.com</a>.</p>
|
||||||
|
<p>This policy was last updated on February 8, 2026.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "footer"}}
|
{{template "footer"}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
{{define "prompts"}}
|
{{define "prompts"}}
|
||||||
<div class="sg-container">
|
<div class="sg-container">
|
||||||
<h1 style="font-size: 2.5rem; font-weight: 700;">{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in</h1>
|
<h1 style="font-size: 2.5rem; font-weight: 700;">{{if .TargetDossier}}{{.TargetDossier.Name}}'s {{end}}Daily Check-in</h1>
|
||||||
<p class="intro" style="font-size: 1.15rem; font-weight: 300; line-height: 1.8;">Track daily measurements and observations</p>
|
<p class="intro" style="font-size: 1.15rem; font-weight: 300; line-height: 1.8; margin-bottom: 8px;">Track daily measurements and observations</p>
|
||||||
|
|
||||||
|
<div class="help-banner" style="background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px 16px; border-radius: 6px; margin-bottom: 24px; font-size: 0.9rem; line-height: 1.6;">
|
||||||
|
<strong>💡 How it works:</strong> Enter what you want to track (e.g., "I take vitamin D every morning" or "walked 30 minutes today").
|
||||||
|
The system will learn patterns and offer to create daily reminders with pre-filled values for fast tracking.
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .Error}}<div class="msg msg-error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="msg msg-error">{{.Error}}</div>{{end}}
|
||||||
{{if .Success}}<div class="msg msg-success">{{.Success}}</div>{{end}}
|
{{if .Success}}<div class="msg msg-success">{{.Success}}</div>{{end}}
|
||||||
|
|
||||||
{{if .Prompts}}
|
{{if or .DuePrompts .UpcomingPrompts .Entries}}
|
||||||
<div class="data-card">
|
<div class="data-card">
|
||||||
<!-- Section header -->
|
<!-- Section header -->
|
||||||
<div class="prompt-section-header">
|
<div class="prompt-section-header">
|
||||||
|
|
@ -18,10 +23,86 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-list">
|
<div class="prompt-list">
|
||||||
|
{{/* 1. FREEFORM CARD - Always visible */}}
|
||||||
|
{{range .DuePrompts}}
|
||||||
|
{{if .IsFreeform}}
|
||||||
|
<div class="prompt-item prompt-freeform" data-prompt-id="{{.ID}}">
|
||||||
|
<form class="prompt-form" data-prompt-id="{{.ID}}">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<span class="prompt-question">{{.Question}}</span>
|
||||||
|
<span class="prompt-due">optional</span>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-body">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: flex-end;">
|
||||||
|
<textarea name="response_raw" class="prompt-textarea" rows="3" style="flex: 1;"
|
||||||
|
placeholder="Type what you want to track... (e.g., 'I take vitamin D every morning' or 'walked 30 minutes today')"
|
||||||
|
onkeydown="if(event.key==='Enter' && (event.metaKey || event.ctrlKey)){event.preventDefault();saveItem(this.closest('.prompt-item'));}"></textarea>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveItem(this.closest('.prompt-item'))" style="align-self: flex-end;">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-hint" style="margin-top: 8px; text-align: right;">
|
||||||
|
<span id="kbd-hint">or press Ctrl+Enter</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* 2. PENDING CARDS - Due but not filled yet */}}
|
||||||
|
{{range .DuePrompts}}
|
||||||
|
{{if not .IsFreeform}}
|
||||||
|
{{if not .HasResponse}}
|
||||||
|
<div class="prompt-item prompt-pending" data-prompt-id="{{.ID}}">
|
||||||
|
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
|
||||||
|
<div class="dismiss-confirm">
|
||||||
|
<span>Stop tracking?</span>
|
||||||
|
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
|
||||||
|
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
|
||||||
|
</div>
|
||||||
|
<form class="prompt-form" data-prompt-id="{{.ID}}">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<span class="prompt-category">{{.Category}}</span>
|
||||||
|
<span class="prompt-question">{{.Question}}</span>
|
||||||
|
<span class="prompt-due">{{.NextAskFormatted}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-body">
|
||||||
|
{{if .Fields}}
|
||||||
|
{{if eq (len .Fields) 1}}
|
||||||
|
{{with index .Fields 0}}
|
||||||
|
{{if eq .Type "number"}}
|
||||||
|
<div class="prompt-input-row">
|
||||||
|
<input type="number" name="field_{{.Key}}"
|
||||||
|
{{if .Min}}min="{{.Min}}"{{end}}
|
||||||
|
{{if .Max}}max="{{.Max}}"{{end}}
|
||||||
|
{{if .Step}}step="{{.Step}}"{{end}}
|
||||||
|
class="prompt-input-number"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
|
||||||
|
{{if .Unit}}<span class="prompt-unit">{{.Unit}}</span>{{end}}
|
||||||
|
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Type "checkbox"}}
|
||||||
|
<label class="prompt-checkbox">
|
||||||
|
<input type="checkbox" name="field_{{.Key}}" value="1">
|
||||||
|
<span class="prompt-checkbox-box"></span>
|
||||||
|
<span class="prompt-checkbox-label">{{if .Label}}{{.Label}}{{else}}Yes{{end}}</span>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* 3. FILLED CARDS - Entries from today */}}
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
<div class="prompt-item entry-item" data-entry-id="{{.ID}}">
|
<div class="prompt-item prompt-filled" data-entry-id="{{.ID}}">
|
||||||
<a href="#" class="prompt-dismiss" onclick="deleteEntry('{{.ID}}'); return false;" title="Delete">✕</a>
|
<a href="#" class="prompt-dismiss" onclick="deleteEntry('{{.ID}}'); return false;" title="Delete">✕</a>
|
||||||
<div class="prompt-header">
|
<div class="prompt-header">
|
||||||
|
<span class="prompt-category">{{.Category}}</span>
|
||||||
<span class="prompt-question">{{.Question}}</span>
|
<span class="prompt-question">{{.Question}}</span>
|
||||||
<span class="prompt-saved-time">{{.TimeFormatted}}</span>
|
<span class="prompt-saved-time">{{.TimeFormatted}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,136 +124,78 @@
|
||||||
<div class="prompt-saved-footer">
|
<div class="prompt-saved-footer">
|
||||||
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
|
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .SourceInput}}<div class="prompt-source">↳ "{{.SourceInput}}"</div>{{end}}
|
{{if .SourceInput}}<div class="prompt-source">Created from: "{{.SourceInput}}"</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{range .Prompts}}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .UpcomingPrompts}}
|
||||||
|
<div class="data-card" style="margin-top: 24px;">
|
||||||
|
<div class="prompt-section-header">
|
||||||
|
<div class="prompt-section-bar" style="background: #94a3b8;"></div>
|
||||||
|
<div class="prompt-section-info">
|
||||||
|
<div class="prompt-section-title">UPCOMING</div>
|
||||||
|
<div class="prompt-section-subtitle">{{len .UpcomingPrompts}} scheduled</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-list">
|
||||||
|
{{range .UpcomingPrompts}}
|
||||||
{{$prompt := .}}
|
{{$prompt := .}}
|
||||||
<div class="prompt-item{{if not .IsDue}} prompt-item-future{{end}}" data-prompt-id="{{.ID}}">
|
<div class="prompt-item prompt-item-future" data-prompt-id="{{.ID}}">
|
||||||
<!-- Dismiss button -->
|
|
||||||
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
|
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '{{.ID}}'); return false;" title="Don't ask again">✕</a>
|
||||||
<div class="dismiss-confirm">
|
<div class="dismiss-confirm">
|
||||||
<span>Stop tracking?</span>
|
<span>Stop tracking?</span>
|
||||||
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
|
<a href="#" onclick="confirmDismiss('{{.ID}}'); return false;">Yes</a>
|
||||||
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
|
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prompt-header">
|
||||||
<!-- Saved state (only show if due AND has response) -->
|
<span class="prompt-category">{{.Category}}</span>
|
||||||
<div class="prompt-saved" style="display: {{if and .HasResponse .IsDue}}block{{else}}none{{end}};">
|
<span class="prompt-question">{{.Question}}</span>
|
||||||
<div class="prompt-saved-header">
|
<span class="prompt-due">{{.NextAskFormatted}}</span>
|
||||||
<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>
|
</div>
|
||||||
|
{{/* Show preview of what the input will look like */}}
|
||||||
<!-- Input state (show if not due, OR if due but no response) -->
|
{{if .Fields}}
|
||||||
<form class="prompt-form" data-prompt-id="{{.ID}}"{{if and .HasResponse .IsDue}} style="display: none;"{{end}}>
|
<div class="prompt-body prompt-preview">
|
||||||
<div class="prompt-header">
|
{{if eq (len .Fields) 1}}
|
||||||
<span class="prompt-question">{{.Question}}</span>
|
{{with index .Fields 0}}
|
||||||
{{if .IsFreeform}}<span class="prompt-due">optional</span>{{else if .NextAsk}}<span class="prompt-due{{if .IsOverdue}} prompt-overdue{{end}}">{{.NextAskFormatted}}</span>{{end}}
|
{{if eq .Type "number"}}
|
||||||
</div>
|
<div class="prompt-input-row">
|
||||||
|
<input type="number" disabled placeholder="Amount" class="prompt-input-number">
|
||||||
<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}}
|
|
||||||
{{if .Value}}value="{{.Value}}"{{end}}
|
|
||||||
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}}>
|
|
||||||
<span class="prompt-checkbox-box"></span>
|
|
||||||
<span class="prompt-checkbox-label">{{if .Label}}{{.Label}}{{else}}Yes{{end}}</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}}
|
|
||||||
{{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}}
|
{{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>
|
</div>
|
||||||
|
{{else if eq .Type "checkbox"}}
|
||||||
|
<label class="prompt-checkbox">
|
||||||
|
<input type="checkbox" disabled>
|
||||||
|
<span class="prompt-checkbox-box"></span>
|
||||||
|
<span class="prompt-checkbox-label">Yes</span>
|
||||||
|
</label>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
|
||||||
<textarea name="response_raw" class="prompt-textarea" rows="3" placeholder="Type your notes..."></textarea>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{{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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state" style="padding: 48px 24px; text-align: center;">
|
||||||
<p>All caught up! No items due right now.</p>
|
<p style="font-size: 1.1rem; margin-bottom: 12px;">✨ No tracking prompts yet</p>
|
||||||
<a href="/dossier/{{.TargetHex}}/prompts?all=1" class="btn btn-secondary">View all items</a>
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
@ -219,6 +242,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
.prompt-item:last-child {
|
.prompt-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|
@ -274,6 +298,18 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.prompt-category {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(198, 93, 7, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
.prompt-question {
|
.prompt-question {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -484,8 +520,24 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.entry-item {
|
.prompt-freeform {
|
||||||
background: #f9f9f9;
|
background: #fefce8;
|
||||||
|
border-left: 4px solid #eab308;
|
||||||
|
}
|
||||||
|
.prompt-pending {
|
||||||
|
background: #fff;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
.prompt-filled {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-left: 4px solid #16a34a;
|
||||||
|
}
|
||||||
|
.prompt-preview {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.prompt-preview input[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
.entry-readonly .prompt-field-row {
|
.entry-readonly .prompt-field-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -502,6 +554,64 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.btn-save {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-save:hover {
|
||||||
|
background: #B45309;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-save:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.btn-save.saving {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.prompt-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.prompt-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.notification.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.notification-success {
|
||||||
|
border-left: 4px solid #16a34a;
|
||||||
|
}
|
||||||
|
.notification-error {
|
||||||
|
border-left: 4px solid #dc2626;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from {
|
from {
|
||||||
|
|
@ -517,16 +627,28 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const targetHex = '{{.TargetHex}}';
|
const targetHex = '{{.TargetHex}}';
|
||||||
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
// Update keyboard hint based on platform
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const hint = document.getElementById('kbd-hint');
|
||||||
|
if (hint && isMac) {
|
||||||
|
hint.textContent = 'or press ⌘+Enter';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.prompt-item').forEach(item => {
|
document.querySelectorAll('.prompt-item').forEach(item => {
|
||||||
item.addEventListener('focusout', (e) => {
|
// Only auto-save on blur for number inputs (quick entry)
|
||||||
setTimeout(() => {
|
item.querySelectorAll('input[type=number]').forEach(input => {
|
||||||
if (!item.contains(document.activeElement)) {
|
input.addEventListener('blur', () => {
|
||||||
saveItem(item);
|
setTimeout(() => {
|
||||||
}
|
if (!item.contains(document.activeElement)) {
|
||||||
}, 100);
|
saveItem(item);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
item.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
item.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||||
cb.addEventListener('change', () => saveItem(item));
|
cb.addEventListener('change', () => saveItem(item));
|
||||||
});
|
});
|
||||||
|
|
@ -699,12 +821,12 @@ async function saveItem(item) {
|
||||||
const form = item.querySelector('.prompt-form');
|
const form = item.querySelector('.prompt-form');
|
||||||
const promptId = form.dataset.promptId;
|
const promptId = form.dataset.promptId;
|
||||||
const inputs = form.querySelectorAll('input:not([type=hidden]), textarea');
|
const inputs = form.querySelectorAll('input:not([type=hidden]), textarea');
|
||||||
|
|
||||||
const response = {};
|
const response = {};
|
||||||
let hasValue = false;
|
let hasValue = false;
|
||||||
let displayValue = '';
|
let displayValue = '';
|
||||||
let responseRaw = '';
|
let responseRaw = '';
|
||||||
|
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
const key = input.name.replace('field_', '').replace('response_raw', 'raw');
|
const key = input.name.replace('field_', '').replace('response_raw', 'raw');
|
||||||
if (input.type === 'checkbox') {
|
if (input.type === 'checkbox') {
|
||||||
|
|
@ -721,9 +843,18 @@ async function saveItem(item) {
|
||||||
responseRaw += (responseRaw ? ' / ' : '') + input.value;
|
responseRaw += (responseRaw ? ' / ' : '') + input.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasValue) return;
|
if (!hasValue) return;
|
||||||
|
|
||||||
|
// Show saving state
|
||||||
|
const saveBtn = item.querySelector('.btn-save, .btn-primary');
|
||||||
|
const originalText = saveBtn ? saveBtn.textContent : '';
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.classList.add('saving');
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dossier/' + targetHex + '/prompts/respond', {
|
const res = await fetch('/dossier/' + targetHex + '/prompts/respond', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -741,20 +872,153 @@ async function saveItem(item) {
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
form.style.display = 'none';
|
// For freeform prompts, just clear and show notification
|
||||||
const saved = item.querySelector('.prompt-saved');
|
if (item.classList.contains('prompt-freeform')) {
|
||||||
saved.querySelector('.prompt-saved-value').textContent = displayValue;
|
form.querySelector('textarea').value = '';
|
||||||
saved.style.display = 'block';
|
showNotification('✓ Saved', 'success');
|
||||||
|
|
||||||
// If LLM generated a new prompt, add it to the deck
|
// If LLM generated a new prompt, add it as a pending card
|
||||||
if (data.new_prompt) {
|
if (data.new_prompt) {
|
||||||
addNewPromptCard(data.new_prompt);
|
addPendingCard(data.new_prompt);
|
||||||
|
showNotification('✓ Saved! Added new tracking prompt.', 'success');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For pending prompts, convert to filled card
|
||||||
|
if (item.classList.contains('prompt-pending')) {
|
||||||
|
// Get the question from the prompt
|
||||||
|
const question = item.querySelector('.prompt-question').textContent;
|
||||||
|
const category = item.querySelector('.prompt-category')?.textContent || '';
|
||||||
|
|
||||||
|
// Create filled card
|
||||||
|
const filledCard = createFilledCard({
|
||||||
|
question: question,
|
||||||
|
category: category,
|
||||||
|
value: displayValue,
|
||||||
|
time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
|
||||||
|
promptId: promptId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert filled card before upcoming section (or at end of today section)
|
||||||
|
const promptList = document.querySelector('.prompt-list');
|
||||||
|
const freeformCard = promptList.querySelector('.prompt-freeform');
|
||||||
|
if (freeformCard && freeformCard.nextElementSibling) {
|
||||||
|
freeformCard.nextElementSibling.insertAdjacentHTML('beforebegin', filledCard);
|
||||||
|
} else {
|
||||||
|
promptList.insertAdjacentHTML('beforeend', filledCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the pending card
|
||||||
|
item.style.opacity = '0';
|
||||||
|
setTimeout(() => item.remove(), 300);
|
||||||
|
|
||||||
|
showNotification('✓ Saved', 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to save. Please try again.', 'error');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.classList.remove('saving');
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save failed:', err);
|
console.error('Save failed:', err);
|
||||||
|
showNotification('Failed to save. Please try again.', 'error');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.classList.remove('saving');
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFilledCard(data) {
|
||||||
|
const now = new Date();
|
||||||
|
return `
|
||||||
|
<div class="prompt-item prompt-filled" style="animation: slideIn 0.3s ease-out;">
|
||||||
|
<a href="#" class="prompt-dismiss" onclick="deleteEntry('${data.promptId}'); return false;" title="Delete">✕</a>
|
||||||
|
<div class="prompt-header">
|
||||||
|
${data.category ? `<span class="prompt-category">${data.category}</span>` : ''}
|
||||||
|
<span class="prompt-question">${data.question}</span>
|
||||||
|
<span class="prompt-saved-time">${data.time}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-body entry-readonly">
|
||||||
|
<div class="prompt-field-row">
|
||||||
|
<span class="entry-value">${data.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-saved-footer">
|
||||||
|
<a href="#" class="prompt-edit" onclick="editEntry(this); return false;">edit</a>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPendingCard(prompt) {
|
||||||
|
// Add a new pending card (from AI prompt generation)
|
||||||
|
const promptList = document.querySelector('.prompt-list');
|
||||||
|
const freeformCard = promptList.querySelector('.prompt-freeform');
|
||||||
|
|
||||||
|
let fieldsHtml = '';
|
||||||
|
if (prompt.input_config && prompt.input_config.fields) {
|
||||||
|
const field = prompt.input_config.fields[0];
|
||||||
|
if (field.type === 'number') {
|
||||||
|
fieldsHtml = `
|
||||||
|
<div class="prompt-input-row">
|
||||||
|
<input type="number" name="field_${field.key}" class="prompt-input-number"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();saveItem(this.closest('.prompt-item'));}">
|
||||||
|
${field.unit ? `<span class="prompt-unit">${field.unit}</span>` : ''}
|
||||||
|
<button type="button" class="btn-save" onclick="saveItem(this.closest('.prompt-item'))">Save</button>
|
||||||
|
</div>`;
|
||||||
|
} else if (field.type === 'checkbox') {
|
||||||
|
fieldsHtml = `
|
||||||
|
<label class="prompt-checkbox">
|
||||||
|
<input type="checkbox" name="field_${field.key}" value="1">
|
||||||
|
<span class="prompt-checkbox-box"></span>
|
||||||
|
<span class="prompt-checkbox-label">Yes</span>
|
||||||
|
</label>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardHtml = `
|
||||||
|
<div class="prompt-item prompt-pending" data-prompt-id="${prompt.id}" style="animation: slideIn 0.3s ease-out;">
|
||||||
|
<a href="#" class="prompt-dismiss" onclick="showDismissConfirm(this, '${prompt.id}'); return false;" title="Don't ask again">✕</a>
|
||||||
|
<div class="dismiss-confirm">
|
||||||
|
<span>Stop tracking?</span>
|
||||||
|
<a href="#" onclick="confirmDismiss('${prompt.id}'); return false;">Yes</a>
|
||||||
|
<a href="#" onclick="hideDismissConfirm(this); return false;">No</a>
|
||||||
|
</div>
|
||||||
|
<form class="prompt-form" data-prompt-id="${prompt.id}">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<span class="prompt-category">${prompt.category}</span>
|
||||||
|
<span class="prompt-question">${prompt.question}</span>
|
||||||
|
<span class="prompt-due">now</span>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-body">
|
||||||
|
${fieldsHtml}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Insert after freeform card
|
||||||
|
if (freeformCard) {
|
||||||
|
freeformCard.insertAdjacentHTML('afterend', cardHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const notif = document.createElement('div');
|
||||||
|
notif.className = `notification notification-${type}`;
|
||||||
|
notif.textContent = message;
|
||||||
|
document.body.appendChild(notif);
|
||||||
|
|
||||||
|
setTimeout(() => notif.classList.add('show'), 10);
|
||||||
|
setTimeout(() => {
|
||||||
|
notif.classList.remove('show');
|
||||||
|
setTimeout(() => notif.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
Loading…
Reference in New Issue