refactor: unexport raw DB functions, enforce RBAC at data layer

Rename Query→dbQuery, Save→dbSave, Load→dbLoad, Delete→dbDelete,
Count→dbCount in lib/db_queries.go. Go compiler now prevents any code
outside lib/ from bypassing RBAC checks.

All external callers migrated to RBAC-checked functions:
- EntryCategoryCounts, EntryCount, EntryListByDossier (new)
- LabTestList, LabEntryListForIndex, LabRefLookupAll (new)
- GenomeQuery now requires AccessContext
- EntryDeleteByCategory/EntryDeleteTree now require AccessContext

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-10 02:34:51 -05:00
parent 77db02a6eb
commit e1b40ab872
25 changed files with 329 additions and 223 deletions

View File

@ -85,7 +85,7 @@ func handleEntries(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown category: "+req.Category, http.StatusBadRequest) http.Error(w, "unknown category: "+req.Category, http.StatusBadRequest)
return return
} }
if err := lib.EntryDeleteByCategory(dossierID, catInt); err != nil { if err := lib.EntryDeleteByCategory(ctx, dossierID, catInt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -107,7 +107,7 @@ func handleEntries(w http.ResponseWriter, r *http.Request) {
if req.Delete && req.ID != "" { if req.Delete && req.ID != "" {
entryID := req.ID entryID := req.ID
if req.DeleteChildren { if req.DeleteChildren {
if err := lib.EntryDeleteTree(req.Dossier, entryID); err != nil { if err := lib.EntryDeleteTree(ctx, req.Dossier, entryID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View File

@ -162,7 +162,7 @@ func v1Dossiers(w http.ResponseWriter, r *http.Request) {
} }
// Get available categories for this dossier // Get available categories for this dossier
categories := getDossierCategories(tid) categories := getDossierCategories(&lib.AccessContext{AccessorID: authID}, tid)
result = append(result, map[string]any{ result = append(result, map[string]any{
"id": d.DossierID, "id": d.DossierID,
@ -176,23 +176,14 @@ func v1Dossiers(w http.ResponseWriter, r *http.Request) {
v1JSON(w, result) v1JSON(w, result)
} }
func getDossierCategories(dossierID string) []string { func getDossierCategories(ctx *lib.AccessContext, dossierID string) []string {
// Query distinct categories for this dossier counts, err := lib.EntryCategoryCounts(ctx, dossierID)
var counts []struct { if err != nil {
Category int `db:"category"` return []string{}
Count int `db:"cnt"`
} }
lib.Query("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? AND category > 0 GROUP BY category", []any{dossierID}, &counts) categories := []string{}
for name := range counts {
categories := []string{} // Empty slice, not nil categories = append(categories, name)
for _, c := range counts {
if c.Count > 0 {
// Use lib.CategoryName to get proper name for all categories
name := lib.CategoryName(c.Category)
if name != "unknown" {
categories = append(categories, name)
}
}
} }
return categories return categories
} }

View File

@ -284,8 +284,8 @@ func main() {
// Save // Save
fmt.Printf("Saving %d entries...\n", len(entries)) fmt.Printf("Saving %d entries...\n", len(entries))
start := time.Now() start := time.Now()
if err := lib.Save("entries", entries); err != nil { if err := lib.EntryAddBatchValues(entries); err != nil {
fmt.Printf("lib.Save failed: %v\n", err) fmt.Printf("EntryAddBatchValues failed: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Done in %v: %d orders (%d created, %d updated), %d total entries\n", fmt.Printf("Done in %v: %d orders (%d created, %d updated), %d total entries\n",
@ -462,7 +462,7 @@ func patchLocalTime(dossierID, inputPath string) {
} }
if e, ok := byKey[order.sourceKey]; ok { if e, ok := byKey[order.sourceKey]; ok {
if patchDataLocalTime(e, order.localTime) { if patchDataLocalTime(e, order.localTime) {
if err := lib.Save("entries", []lib.Entry{*e}); err == nil { if err := lib.EntryAddBatchValues([]lib.Entry{*e}); err == nil {
patched++ patched++
} }
} }

View File

@ -12,15 +12,21 @@ func main() {
} }
lib.ConfigInit() lib.ConfigInit()
// Get all dossiers with lab entries // Get all dossiers
allDossiers, err := lib.DossierList(nil, nil) // nil ctx = system, nil filter = all
if err != nil {
log.Fatal("List dossiers:", err)
}
type dossierRow struct { type dossierRow struct {
DossierID string `db:"dossier_id"` DossierID string
Count int `db:"count"` Count int
} }
var dossiers []dossierRow var dossiers []dossierRow
if err := lib.Query("SELECT dossier_id, COUNT(*) as count FROM entries WHERE category = 3 GROUP BY dossier_id", for _, d := range allDossiers {
[]any{}, &dossiers); err != nil { count, _ := lib.EntryCount(nil, d.DossierID, lib.CategoryLab, "")
log.Fatal("Query dossiers:", err) if count > 0 {
dossiers = append(dossiers, dossierRow{DossierID: d.DossierID, Count: count})
}
} }
fmt.Printf("Found %d dossiers with lab data\n", len(dossiers)) fmt.Printf("Found %d dossiers with lab data\n", len(dossiers))

View File

@ -50,12 +50,7 @@ func main() {
} }
// Get all document entries for Anastasiia // Get all document entries for Anastasiia
var docs []lib.Entry docs, err := lib.EntryQuery(dossierID, lib.CategoryDocument, "")
err := lib.Query(
"SELECT entry_id, value, data, timestamp FROM entries WHERE dossier_id = ? AND category = ?",
[]interface{}{dossierID, lib.CategoryDocument},
&docs,
)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Query: %v\n", err) fmt.Fprintf(os.Stderr, "Query: %v\n", err)
os.Exit(1) os.Exit(1)
@ -144,7 +139,7 @@ func createEventEntry(sourceID string, event map[string]interface{}) error {
Data: string(dataJSON), Data: string(dataJSON),
} }
return lib.Save("entries", &entry) return lib.EntryAdd(&entry)
} }
func createAssessmentEntry(sourceID string, assessment map[string]interface{}) error { func createAssessmentEntry(sourceID string, assessment map[string]interface{}) error {
@ -170,27 +165,29 @@ func createAssessmentEntry(sourceID string, assessment map[string]interface{}) e
Data: string(dataJSON), Data: string(dataJSON),
} }
return lib.Save("entries", &entry) return lib.EntryAdd(&entry)
} }
func deleteEvents() { func deleteEvents() {
fmt.Println("Deleting entries with tag:", eventTag) fmt.Println("Deleting entries with tag:", eventTag)
var entries []lib.Entry entries, err := lib.EntryQuery(dossierID, -1, "")
err := lib.Query(
"SELECT entry_id FROM entries WHERE tags LIKE ?",
[]interface{}{"%" + eventTag + "%"},
&entries,
)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Query: %v\n", err) fmt.Fprintf(os.Stderr, "Query: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Found %d entries to delete\n", len(entries)) var toDelete []lib.Entry
for _, e := range entries { for _, e := range entries {
if err := lib.Delete("entries", "entry_id", e.EntryID); err != nil { if e.Tags != "" && e.Tags == eventTag {
toDelete = append(toDelete, *e)
}
}
fmt.Printf("Found %d entries to delete\n", len(toDelete))
for _, e := range toDelete {
if err := lib.EntryDelete(e.EntryID); err != nil {
fmt.Fprintf(os.Stderr, "Delete %s: %v\n", e.EntryID, err) fmt.Fprintf(os.Stderr, "Delete %s: %v\n", e.EntryID, err)
} }
} }

View File

@ -211,7 +211,7 @@ func processDocument(filePath, filename string) error {
Data: string(dataJSON), Data: string(dataJSON),
} }
if err := lib.Save("entries", &entry); err != nil { if err := lib.EntryAdd(&entry); err != nil {
return fmt.Errorf("save entry: %w", err) return fmt.Errorf("save entry: %w", err)
} }
@ -221,17 +221,23 @@ func processDocument(filePath, filename string) error {
func deleteImported() { func deleteImported() {
fmt.Println("Deleting entries with tag:", batchTag) fmt.Println("Deleting entries with tag:", batchTag)
var entries []lib.Entry entries, err := lib.EntryQuery(dossierID, -1, "")
err := lib.Query("SELECT entry_id FROM entries WHERE tags LIKE ?", []interface{}{"%" + batchTag + "%"}, &entries)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Query: %v\n", err) fmt.Fprintf(os.Stderr, "Query: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Found %d entries to delete\n", len(entries)) var toDelete []*lib.Entry
for _, e := range entries { for _, e := range entries {
if err := lib.Delete("entries", "entry_id", e.EntryID); err != nil { if strings.Contains(e.Tags, batchTag) {
toDelete = append(toDelete, e)
}
}
fmt.Printf("Found %d entries to delete\n", len(toDelete))
for _, e := range toDelete {
if err := lib.EntryDelete(e.EntryID); err != nil {
fmt.Fprintf(os.Stderr, "Delete %s: %v\n", e.EntryID, err) fmt.Fprintf(os.Stderr, "Delete %s: %v\n", e.EntryID, err)
} }
} }

View File

@ -40,8 +40,13 @@ func main() {
fmt.Println("-- DOSSIERS") fmt.Println("-- DOSSIERS")
var dossiers []*lib.Dossier var dossiers []*lib.Dossier
lib.Query(`SELECT * FROM dossiers WHERE dossier_id IN (?, ?, ?, ?, ?)`, for _, id := range keepIDs {
[]any{keepIDs[0], keepIDs[1], keepIDs[2], keepIDs[3], keepIDs[4]}, &dossiers) d, err := lib.DossierGet(nil, id)
if err != nil {
continue
}
dossiers = append(dossiers, d)
}
for _, d := range dossiers { for _, d := range dossiers {
fmt.Printf("INSERT INTO dossiers (dossier_id, email_hash, email, name, date_of_birth, sex, phone, language, timezone, created_at, weight_unit, height_unit) VALUES ('%s', '%s', '%s', '%s', '%s', %d, '%s', '%s', '%s', %d, '%s', '%s');\n", fmt.Printf("INSERT INTO dossiers (dossier_id, email_hash, email, name, date_of_birth, sex, phone, language, timezone, created_at, weight_unit, height_unit) VALUES ('%s', '%s', '%s', '%s', '%s', %d, '%s', '%s', '%s', %d, '%s', '%s');\n",
d.DossierID, esc(d.EmailHash), esc(d.Email), esc(d.Name), esc(d.DateOfBirth), d.Sex, esc(d.Phone), esc(d.Language), esc(d.Timezone), d.CreatedAt, esc(d.WeightUnit), esc(d.HeightUnit)) d.DossierID, esc(d.EmailHash), esc(d.Email), esc(d.Name), esc(d.DateOfBirth), d.Sex, esc(d.Phone), esc(d.Language), esc(d.Timezone), d.CreatedAt, esc(d.WeightUnit), esc(d.HeightUnit))
@ -49,11 +54,16 @@ func main() {
fmt.Println("\n-- DOSSIER_ACCESS") fmt.Println("\n-- DOSSIER_ACCESS")
var accesses []*lib.DossierAccess var accesses []*lib.DossierAccess
lib.Query(`SELECT * FROM dossier_access for _, id := range keepIDs {
WHERE accessor_dossier_id IN (?, ?, ?, ?, ?) list, _ := lib.AccessListByAccessor(id)
AND target_dossier_id IN (?, ?, ?, ?, ?)`, for _, a := range list {
[]any{keepIDs[0], keepIDs[1], keepIDs[2], keepIDs[3], keepIDs[4], for _, kid := range keepIDs {
keepIDs[0], keepIDs[1], keepIDs[2], keepIDs[3], keepIDs[4]}, &accesses) if a.TargetDossierID == kid {
accesses = append(accesses, a)
}
}
}
}
for _, a := range accesses { for _, a := range accesses {
isCare := 0 isCare := 0
if a.IsCareReceiver { isCare = 1 } if a.IsCareReceiver { isCare = 1 }

View File

@ -60,7 +60,7 @@ EXAMPLE:
DATABASE: DATABASE:
SNPedia reference: ~/dev/inou/snpedia-genotypes/genotypes.db (read-only) SNPedia reference: ~/dev/inou/snpedia-genotypes/genotypes.db (read-only)
Entries: via lib.Save() to /tank/inou/data/inou.db Entries: via lib.EntryAddBatchValues() to /tank/inou/data/inou.db
VERSION: ` + version) VERSION: ` + version)
} }
@ -460,7 +460,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if err := lib.EntryDeleteByCategory(dossierID, lib.CategoryGenome); err != nil { if err := lib.EntryDeleteByCategory(nil, dossierID, lib.CategoryGenome); err != nil { // nil ctx = system import
fmt.Println("Delete existing failed:", err) fmt.Println("Delete existing failed:", err)
os.Exit(1) os.Exit(1)
} }
@ -553,11 +553,11 @@ func main() {
// ===== PHASE 8: Save to database ===== // ===== PHASE 8: Save to database =====
phase8Start := time.Now() phase8Start := time.Now()
if err := lib.Save("entries", entries); err != nil { if err := lib.EntryAddBatchValues(entries); err != nil {
fmt.Println("lib.Save failed:", err) fmt.Println("EntryAddBatchValues failed:", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Phase 8 - lib.Save: %v (%d entries saved)\n", time.Since(phase8Start), len(entries)) fmt.Printf("Phase 8 - Save: %v (%d entries saved)\n", time.Since(phase8Start), len(entries))
fmt.Printf("\nTOTAL: %v\n", time.Since(totalStart)) fmt.Printf("\nTOTAL: %v\n", time.Since(totalStart))
fmt.Printf("Extraction ID: %s\n", extractionID) fmt.Printf("Extraction ID: %s\n", extractionID)

View File

@ -261,7 +261,7 @@ func accessGrantListRaw(f *PermissionFilter) ([]*Access, error) {
q += " ORDER BY created_at DESC" q += " ORDER BY created_at DESC"
var result []*Access var result []*Access
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -312,7 +312,7 @@ func GrantAccess(dossierID, granteeID, entryID, ops string) error {
Ops: ops, Ops: ops,
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
} }
err := Save("access", grant) err := dbSave("access", grant)
if err == nil { if err == nil {
InvalidateCacheForAccessor(granteeID) InvalidateCacheForAccessor(granteeID)
} }
@ -321,10 +321,10 @@ func GrantAccess(dossierID, granteeID, entryID, ops string) error {
func RevokeAccess(accessID string) error { func RevokeAccess(accessID string) error {
var grant Access var grant Access
if err := Load("access", accessID, &grant); err != nil { if err := dbLoad("access", accessID, &grant); err != nil {
return err return err
} }
err := Delete("access", "access_id", accessID) err := dbDelete("access", "access_id", accessID)
if err == nil { if err == nil {
InvalidateCacheForAccessor(grant.GranteeID) InvalidateCacheForAccessor(grant.GranteeID)
} }

View File

@ -97,19 +97,22 @@ func EntryDelete(entryID string) error {
return EntryRemove(nil, entryID) // nil ctx = internal operation return EntryRemove(nil, entryID) // nil ctx = internal operation
} }
// EntryDeleteTree removes an entry and all its children // EntryDeleteTree removes an entry and all its children. Requires delete permission.
func EntryDeleteTree(dossierID, entryID string) error { func EntryDeleteTree(ctx *AccessContext, dossierID, entryID string) error {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, entryID, 0, 'd'); err != nil {
return err
}
// Delete children first // Delete children first
var children []*Entry var children []*Entry
if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ? AND parent_id = ?", []any{dossierID, entryID}, &children); err != nil { if err := dbQuery("SELECT entry_id FROM entries WHERE dossier_id = ? AND parent_id = ?", []any{dossierID, entryID}, &children); err != nil {
return err return err
} }
for _, c := range children { for _, c := range children {
if err := Delete("entries", "entry_id", c.EntryID); err != nil { if err := dbDelete("entries", "entry_id", c.EntryID); err != nil {
return err return err
} }
} }
return Delete("entries", "entry_id", entryID) return dbDelete("entries", "entry_id", entryID)
} }
// EntryModify updates an entry (internal operation) // EntryModify updates an entry (internal operation)
@ -132,41 +135,41 @@ func EntryQuery(dossierID string, category int, typ string) ([]*Entry, error) {
} }
q += " ORDER BY timestamp DESC" q += " ORDER BY timestamp DESC"
var result []*Entry var result []*Entry
return result, Query(q, args, &result) return result, dbQuery(q, args, &result)
} }
// EntryQueryByDate retrieves entries within a timestamp range // EntryQueryByDate retrieves entries within a timestamp range
func EntryQueryByDate(dossierID string, from, to int64) ([]*Entry, error) { func EntryQueryByDate(dossierID string, from, to int64) ([]*Entry, error) {
var result []*Entry var result []*Entry
return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND timestamp >= ? AND timestamp < ? ORDER BY timestamp DESC", return result, dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND timestamp >= ? AND timestamp < ? ORDER BY timestamp DESC",
[]any{dossierID, from, to}, &result) []any{dossierID, from, to}, &result)
} }
// EntryChildren retrieves child entries ordered by ordinal // EntryChildren retrieves child entries ordered by ordinal
func EntryChildren(dossierID, parentID string) ([]*Entry, error) { func EntryChildren(dossierID, parentID string) ([]*Entry, error) {
var result []*Entry var result []*Entry
return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? ORDER BY ordinal", return result, dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? ORDER BY ordinal",
[]any{dossierID, parentID}, &result) []any{dossierID, parentID}, &result)
} }
// EntryChildrenByCategory retrieves child entries filtered by category, ordered by ordinal // EntryChildrenByCategory retrieves child entries filtered by category, ordered by ordinal
func EntryChildrenByCategory(dossierID, parentID string, category int) ([]*Entry, error) { func EntryChildrenByCategory(dossierID, parentID string, category int) ([]*Entry, error) {
var result []*Entry var result []*Entry
return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND category = ? ORDER BY ordinal", return result, dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND category = ? ORDER BY ordinal",
[]any{dossierID, parentID, category}, &result) []any{dossierID, parentID, category}, &result)
} }
// EntryChildrenByType retrieves child entries filtered by type string, ordered by ordinal // EntryChildrenByType retrieves child entries filtered by type string, ordered by ordinal
func EntryChildrenByType(dossierID, parentID string, typ string) ([]*Entry, error) { func EntryChildrenByType(dossierID, parentID string, typ string) ([]*Entry, error) {
var result []*Entry var result []*Entry
return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND type = ? ORDER BY ordinal", return result, dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND parent_id = ? AND type = ? ORDER BY ordinal",
[]any{dossierID, parentID, CryptoEncrypt(typ)}, &result) []any{dossierID, parentID, CryptoEncrypt(typ)}, &result)
} }
// EntryRootByType finds the root entry (parent_id = 0 or NULL) for a given type // EntryRootByType finds the root entry (parent_id = 0 or NULL) for a given type
func EntryRootByType(dossierID string, typ string) (*Entry, error) { func EntryRootByType(dossierID string, typ string) (*Entry, error) {
var result []*Entry var result []*Entry
err := Query("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') LIMIT 1", err := dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') LIMIT 1",
[]any{dossierID, CryptoEncrypt(typ)}, &result) []any{dossierID, CryptoEncrypt(typ)}, &result)
if err != nil { if err != nil {
return nil, err return nil, err
@ -180,14 +183,14 @@ func EntryRootByType(dossierID string, typ string) (*Entry, error) {
// EntryRootsByType finds all root entries (parent_id = '' or NULL) for a given type // EntryRootsByType finds all root entries (parent_id = '' or NULL) for a given type
func EntryRootsByType(dossierID string, typ string) ([]*Entry, error) { func EntryRootsByType(dossierID string, typ string) ([]*Entry, error) {
var result []*Entry var result []*Entry
return result, Query("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') ORDER BY timestamp DESC", return result, dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND type = ? AND (parent_id IS NULL OR parent_id = '' OR parent_id = '0') ORDER BY timestamp DESC",
[]any{dossierID, CryptoEncrypt(typ)}, &result) []any{dossierID, CryptoEncrypt(typ)}, &result)
} }
// EntryRootByCategory finds the root entry (parent_id IS NULL) for a category // EntryRootByCategory finds the root entry (parent_id IS NULL) for a category
func EntryRootByCategory(dossierID string, category int) (*Entry, error) { func EntryRootByCategory(dossierID string, category int) (*Entry, error) {
var result []*Entry var result []*Entry
err := Query("SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND (parent_id IS NULL OR parent_id = '') LIMIT 1", err := dbQuery("SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND (parent_id IS NULL OR parent_id = '') LIMIT 1",
[]any{dossierID, category}, &result) []any{dossierID, category}, &result)
if err != nil { if err != nil {
return nil, err return nil, err
@ -201,7 +204,7 @@ func EntryRootByCategory(dossierID string, category int) (*Entry, error) {
// EntryTypes returns distinct types for a dossier+category // EntryTypes returns distinct types for a dossier+category
func EntryTypes(dossierID string, category int) ([]string, error) { func EntryTypes(dossierID string, category int) ([]string, error) {
var entries []*Entry var entries []*Entry
if err := Query("SELECT DISTINCT type FROM entries WHERE dossier_id = ? AND category = ?", if err := dbQuery("SELECT DISTINCT type FROM entries WHERE dossier_id = ? AND category = ?",
[]any{dossierID, category}, &entries); err != nil { []any{dossierID, category}, &entries); err != nil {
return nil, err return nil, err
} }
@ -304,16 +307,19 @@ func boolToInt(b bool) int {
return 0 return 0
} }
// EntryDeleteByCategory removes all entries with a given category for a dossier // EntryDeleteByCategory removes all entries with a given category for a dossier. Requires delete permission.
func EntryDeleteByCategory(dossierID string, category int) error { func EntryDeleteByCategory(ctx *AccessContext, dossierID string, category int) error {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", category, 'd'); err != nil {
return err
}
// Query all entries with this category, then delete each // Query all entries with this category, then delete each
var entries []*Entry var entries []*Entry
if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ? AND category = ?", if err := dbQuery("SELECT entry_id FROM entries WHERE dossier_id = ? AND category = ?",
[]any{dossierID, category}, &entries); err != nil { []any{dossierID, category}, &entries); err != nil {
return err return err
} }
for _, e := range entries { for _, e := range entries {
if err := Delete("entries", "entry_id", e.EntryID); err != nil { if err := dbDelete("entries", "entry_id", e.EntryID); err != nil {
return err return err
} }
} }
@ -330,6 +336,11 @@ func EntryAddBatch(entries []*Entry) error {
return EntryWrite(nil, entries...) // nil ctx = internal operation return EntryWrite(nil, entries...) // nil ctx = internal operation
} }
// EntryAddBatchValues inserts multiple entries from a value slice (internal operation)
func EntryAddBatchValues(entries []Entry) error {
return dbSave("entries", entries)
}
// DossierSetSessionToken sets the mobile session token (internal/auth operation) // DossierSetSessionToken sets the mobile session token (internal/auth operation)
func DossierSetSessionToken(dossierID string, token string) error { func DossierSetSessionToken(dossierID string, token string) error {
d, err := DossierGet(nil, dossierID) // nil ctx = internal operation d, err := DossierGet(nil, dossierID) // nil ctx = internal operation

View File

@ -4,7 +4,8 @@ package lib
// ⛔ CRITICAL: DO NOT MODIFY THIS FILE WITHOUT JOHAN'S EXPRESS CONSENT // ⛔ CRITICAL: DO NOT MODIFY THIS FILE WITHOUT JOHAN'S EXPRESS CONSENT
// ============================================================================ // ============================================================================
// This is the ONLY file allowed to access the database directly. // This is the ONLY file allowed to access the database directly.
// All other code must use these functions: Save, Load, Query, Delete, Count // Internal DB functions (unexported): dbSave, dbLoad, dbQuery, dbDelete, dbCount
// External code must use RBAC-checked functions (EntryWrite, DossierGet, etc.)
// //
// Run `make check-db` to verify no direct DB access exists elsewhere. // Run `make check-db` to verify no direct DB access exists elsewhere.
// ============================================================================ // ============================================================================
@ -230,11 +231,11 @@ func VerifyAll(pairs ...any) error {
return nil return nil
} }
// Save upserts struct(s) to the database. // dbSave upserts struct(s) to the database.
// Accepts a single struct or a slice of structs. // Accepts a single struct or a slice of structs.
// String and []byte fields are encrypted automatically. // String and []byte fields are encrypted automatically.
// Slices are wrapped in a transaction for atomicity. // Slices are wrapped in a transaction for atomicity.
func Save(table string, v any) error { func dbSave(table string, v any) error {
start := time.Now() start := time.Now()
defer func() { logSlowQuery("INSERT OR REPLACE INTO "+table, time.Since(start)) }() defer func() { logSlowQuery("INSERT OR REPLACE INTO "+table, time.Since(start)) }()
@ -333,9 +334,9 @@ func Save(table string, v any) error {
return err return err
} }
// Load retrieves a record by primary key and populates the struct. // dbLoad retrieves a record by primary key and populates the struct.
// String and []byte fields are decrypted automatically. // String and []byte fields are decrypted automatically.
func Load(table string, id string, v any) error { func dbLoad(table string, id string, v any) error {
start := time.Now() start := time.Now()
defer func() { logSlowQuery("SELECT FROM "+table+" WHERE pk=?", time.Since(start), id) }() defer func() { logSlowQuery("SELECT FROM "+table+" WHERE pk=?", time.Since(start), id) }()
@ -383,10 +384,10 @@ func Load(table string, id string, v any) error {
return nil return nil
} }
// Query runs a SQL query and populates the slice. // dbQuery runs a SQL query and populates the slice.
// Column names in result must match struct db tags. // Column names in result must match struct db tags.
// String and []byte fields are decrypted automatically. // String and []byte fields are decrypted automatically.
func Query(query string, args []any, slicePtr any) error { func dbQuery(query string, args []any, slicePtr any) error {
start := time.Now() start := time.Now()
defer func() { logSlowQuery(query, time.Since(start), args...) }() defer func() { logSlowQuery(query, time.Since(start), args...) }()
@ -467,26 +468,25 @@ func Query(query string, args []any, slicePtr any) error {
return nil return nil
} }
// Count runs a SELECT COUNT(*) query and returns the result. // dbCount runs a SELECT COUNT(*) query and returns the result.
// Example: Count("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?", dossierID, category) func dbCount(query string, args ...any) (int, error) {
func Count(query string, args ...any) (int, error) {
var count int var count int
err := db.QueryRow(query, args...).Scan(&count) err := db.QueryRow(query, args...).Scan(&count)
return count, err return count, err
} }
// Delete removes a record by primary key. // dbDelete removes a record by primary key.
// pkCol is the primary key column name, id is 16-char hex string. // pkCol is the primary key column name, id is 16-char hex string.
func Delete(table, pkCol, id string) error { func dbDelete(table, pkCol, id string) error {
query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, pkCol) query := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", table, pkCol)
_, err := db.Exec(query, id) _, err := db.Exec(query, id)
return err return err
} }
// DeleteTree removes a record and all its descendants. // dbDeleteTree removes a record and all its descendants.
// Traverses the parent-child hierarchy recursively, deletes children first. // Traverses the parent-child hierarchy recursively, deletes children first.
// Works with any SQL database (no CTEs or CASCADE needed). // Works with any SQL database (no CTEs or CASCADE needed).
func DeleteTree(table, pkCol, parentCol, id string) error { func dbDeleteTree(table, pkCol, parentCol, id string) error {
// Collect all IDs (parent + descendants) // Collect all IDs (parent + descendants)
var ids []string var ids []string
var collect func(string) error var collect func(string) error

View File

@ -177,7 +177,7 @@ func CreateJournal(input CreateJournalInput) (string, error) {
Status: input.Status, // defaults to 0 (draft) if not set Status: input.Status, // defaults to 0 (draft) if not set
} }
if err := Save("entries", &entry); err != nil { if err := dbSave("entries", &entry); err != nil {
return "", fmt.Errorf("failed to save entry: %w", err) return "", fmt.Errorf("failed to save entry: %w", err)
} }
@ -187,7 +187,7 @@ func CreateJournal(input CreateJournalInput) (string, error) {
// GetJournal retrieves a full journal entry // GetJournal retrieves a full journal entry
func GetJournal(dossierID, entryID string) (*JournalEntry, error) { func GetJournal(dossierID, entryID string) (*JournalEntry, error) {
var entry Entry var entry Entry
if err := Load("entries", entryID, &entry); err != nil { if err := dbLoad("entries", entryID, &entry); err != nil {
return nil, fmt.Errorf("failed to load entry: %w", err) return nil, fmt.Errorf("failed to load entry: %w", err)
} }
@ -271,7 +271,7 @@ func ListJournals(input ListJournalsInput) ([]JournalSummary, error) {
// Execute query // Execute query
var entries []Entry var entries []Entry
if err := Query(query, args, &entries); err != nil { if err := dbQuery(query, args, &entries); err != nil {
return nil, fmt.Errorf("failed to query entries: %w", err) return nil, fmt.Errorf("failed to query entries: %w", err)
} }
@ -310,7 +310,7 @@ type UpdateJournalStatusInput struct {
func UpdateJournalStatus(input UpdateJournalStatusInput) error { func UpdateJournalStatus(input UpdateJournalStatusInput) error {
// Load entry // Load entry
var entry Entry var entry Entry
if err := Load("entries", input.EntryID, &entry); err != nil { if err := dbLoad("entries", input.EntryID, &entry); err != nil {
return fmt.Errorf("failed to load entry: %w", err) return fmt.Errorf("failed to load entry: %w", err)
} }
@ -351,7 +351,7 @@ func UpdateJournalStatus(input UpdateJournalStatusInput) error {
} }
// Save entry // Save entry
if err := Save("entries", &entry); err != nil { if err := dbSave("entries", &entry); err != nil {
return fmt.Errorf("failed to save entry: %w", err) return fmt.Errorf("failed to save entry: %w", err)
} }

View File

@ -60,7 +60,7 @@ func MakeRefID(loinc, source, sex string, ageDays int64) string {
// LabTestGet retrieves a LabTest by LOINC code. Returns nil if not found. // LabTestGet retrieves a LabTest by LOINC code. Returns nil if not found.
func LabTestGet(loincID string) (*LabTest, error) { func LabTestGet(loincID string) (*LabTest, error) {
var t LabTest var t LabTest
if err := Load("lab_test", loincID, &t); err != nil { if err := dbLoad("lab_test", loincID, &t); err != nil {
return nil, err return nil, err
} }
return &t, nil return &t, nil
@ -68,7 +68,7 @@ func LabTestGet(loincID string) (*LabTest, error) {
// LabTestSave upserts a LabTest record. // LabTestSave upserts a LabTest record.
func LabTestSave(t *LabTest) error { func LabTestSave(t *LabTest) error {
return Save("lab_test", t) return dbSave("lab_test", t)
} }
// LabTestSaveBatch upserts multiple LabTest records. // LabTestSaveBatch upserts multiple LabTest records.
@ -76,13 +76,13 @@ func LabTestSaveBatch(tests []LabTest) error {
if len(tests) == 0 { if len(tests) == 0 {
return nil return nil
} }
return Save("lab_test", tests) return dbSave("lab_test", tests)
} }
// LabRefSave upserts a LabReference record (auto-generates ref_id). // LabRefSave upserts a LabReference record (auto-generates ref_id).
func LabRefSave(r *LabReference) error { func LabRefSave(r *LabReference) error {
r.RefID = MakeRefID(r.LoincID, r.Source, r.Sex, r.AgeDays) r.RefID = MakeRefID(r.LoincID, r.Source, r.Sex, r.AgeDays)
return Save("lab_reference", r) return dbSave("lab_reference", r)
} }
// LabRefSaveBatch upserts multiple LabReference records (auto-generates ref_ids). // LabRefSaveBatch upserts multiple LabReference records (auto-generates ref_ids).
@ -93,14 +93,21 @@ func LabRefSaveBatch(refs []LabReference) error {
for i := range refs { for i := range refs {
refs[i].RefID = MakeRefID(refs[i].LoincID, refs[i].Source, refs[i].Sex, refs[i].AgeDays) refs[i].RefID = MakeRefID(refs[i].LoincID, refs[i].Source, refs[i].Sex, refs[i].AgeDays)
} }
return Save("lab_reference", refs) return dbSave("lab_reference", refs)
}
// LabRefLookupAll returns all reference ranges for a LOINC code.
func LabRefLookupAll(loincID string) ([]LabReference, error) {
var refs []LabReference
return refs, dbQuery("SELECT ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit FROM lab_reference WHERE loinc_id = ?",
[]any{loincID}, &refs)
} }
// LabRefLookup finds the matching reference range for a test at a given age/sex. // LabRefLookup finds the matching reference range for a test at a given age/sex.
// Returns nil if no matching reference found. // Returns nil if no matching reference found.
func LabRefLookup(loincID, sex string, ageDays int64) (*LabReference, error) { func LabRefLookup(loincID, sex string, ageDays int64) (*LabReference, error) {
var refs []LabReference var refs []LabReference
if err := Query( if err := dbQuery(
"SELECT ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit FROM lab_reference WHERE loinc_id = ?", "SELECT ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit FROM lab_reference WHERE loinc_id = ?",
[]any{loincID}, &refs, []any{loincID}, &refs,
); err != nil { ); err != nil {
@ -154,13 +161,13 @@ func PopulateReferences() error {
// Load all lab_test entries // Load all lab_test entries
var tests []LabTest var tests []LabTest
if err := Query("SELECT loinc_id, name, si_unit, direction, si_factor FROM lab_test", nil, &tests); err != nil { if err := dbQuery("SELECT loinc_id, name, si_unit, direction, si_factor FROM lab_test", nil, &tests); err != nil {
return fmt.Errorf("load lab_test: %w", err) return fmt.Errorf("load lab_test: %w", err)
} }
// Find which ones already have references // Find which ones already have references
var existingRefs []LabReference var existingRefs []LabReference
if err := Query("SELECT ref_id, loinc_id FROM lab_reference", nil, &existingRefs); err != nil { if err := dbQuery("SELECT ref_id, loinc_id FROM lab_reference", nil, &existingRefs); err != nil {
return fmt.Errorf("load lab_reference: %w", err) return fmt.Errorf("load lab_reference: %w", err)
} }
hasRef := make(map[string]bool) hasRef := make(map[string]bool)

View File

@ -23,7 +23,7 @@ func Normalize(dossierID string, category int) error {
Type string `db:"type"` Type string `db:"type"`
} }
var rows []typeRow var rows []typeRow
if err := Query("SELECT type FROM entries WHERE dossier_id = ? AND category = ? GROUP BY type", if err := dbQuery("SELECT type FROM entries WHERE dossier_id = ? AND category = ? GROUP BY type",
[]any{dossierID, category}, &rows); err != nil { []any{dossierID, category}, &rows); err != nil {
return fmt.Errorf("query unique types: %w", err) return fmt.Errorf("query unique types: %w", err)
} }
@ -185,7 +185,7 @@ func Normalize(dossierID string, category int) error {
} }
log.Printf("normalize: updating %d entries", len(toSave)) log.Printf("normalize: updating %d entries", len(toSave))
return Save("entries", toSave) return dbSave("entries", toSave)
} }
// normalizeKey reduces a test name to a heuristic grouping key. // normalizeKey reduces a test name to a heuristic grouping key.

View File

@ -20,31 +20,31 @@ func TrackerAdd(p *Tracker) error {
if p.Active == false && p.Dismissed == false { if p.Active == false && p.Dismissed == false {
p.Active = true // default to active p.Active = true // default to active
} }
return Save("trackers", p) return dbSave("trackers", p)
} }
// TrackerModify updates an existing prompt // TrackerModify updates an existing prompt
func TrackerModify(p *Tracker) error { func TrackerModify(p *Tracker) error {
p.UpdatedAt = time.Now().Unix() p.UpdatedAt = time.Now().Unix()
return Save("trackers", p) return dbSave("trackers", p)
} }
// TrackerDelete removes a prompt // TrackerDelete removes a prompt
func TrackerDelete(trackerID string) error { func TrackerDelete(trackerID string) error {
return Delete("trackers", "tracker_id", trackerID) return dbDelete("trackers", "tracker_id", trackerID)
} }
// TrackerGet retrieves a single tracker by ID // TrackerGet retrieves a single tracker by ID
func TrackerGet(trackerID string) (*Tracker, error) { func TrackerGet(trackerID string) (*Tracker, error) {
p := &Tracker{} p := &Tracker{}
return p, Load("trackers", trackerID, p) return p, dbLoad("trackers", trackerID, p)
} }
// TrackerQueryActive retrieves active trackers due for a dossier // TrackerQueryActive retrieves active trackers due for a dossier
func TrackerQueryActive(dossierID string) ([]*Tracker, error) { func TrackerQueryActive(dossierID string) ([]*Tracker, error) {
now := time.Now().Unix() now := time.Now().Unix()
var result []*Tracker var result []*Tracker
err := Query(`SELECT * FROM trackers err := dbQuery(`SELECT * FROM trackers
WHERE dossier_id = ? AND active = 1 AND dismissed = 0 WHERE dossier_id = ? AND active = 1 AND dismissed = 0
AND (expires_at = 0 OR expires_at > ?) AND (expires_at = 0 OR expires_at > ?)
ORDER BY ORDER BY
@ -56,7 +56,7 @@ func TrackerQueryActive(dossierID string) ([]*Tracker, error) {
// TrackerQueryAll retrieves all trackers for a dossier (including inactive) // TrackerQueryAll retrieves all trackers for a dossier (including inactive)
func TrackerQueryAll(dossierID string) ([]*Tracker, error) { func TrackerQueryAll(dossierID string) ([]*Tracker, error) {
var result []*Tracker var result []*Tracker
err := Query(`SELECT * FROM trackers WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`, err := dbQuery(`SELECT * FROM trackers WHERE dossier_id = ? ORDER BY active DESC, time_of_day, created_at`,
[]any{dossierID}, &result) []any{dossierID}, &result)
return result, err return result, err
} }
@ -77,7 +77,7 @@ func TrackerRespond(trackerID string, response, responseRaw string) error {
p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now) p.NextAsk = calculateNextAsk(p.Frequency, p.TimeOfDay, now)
p.UpdatedAt = now p.UpdatedAt = now
if err := Save("trackers", p); err != nil { if err := dbSave("trackers", p); err != nil {
return err return err
} }
@ -149,7 +149,7 @@ func TrackerDismiss(trackerID string) error {
} }
p.Dismissed = true p.Dismissed = true
p.UpdatedAt = time.Now().Unix() p.UpdatedAt = time.Now().Unix()
return Save("trackers", p) return dbSave("trackers", p)
} }
// TrackerSkip advances next_ask to tomorrow without recording a response // TrackerSkip advances next_ask to tomorrow without recording a response
@ -161,7 +161,7 @@ func TrackerSkip(trackerID string) error {
now := time.Now().Unix() now := time.Now().Unix()
p.NextAsk = now + 24*60*60 p.NextAsk = now + 24*60*60
p.UpdatedAt = now p.UpdatedAt = now
return Save("trackers", p) return dbSave("trackers", p)
} }
// calculateNextAsk determines when to ask again based on frequency // calculateNextAsk determines when to ask again based on frequency

156
lib/v2.go
View File

@ -73,9 +73,9 @@ func EntryWrite(ctx *AccessContext, entries ...*Entry) error {
} }
} }
if len(entries) == 1 { if len(entries) == 1 {
return Save("entries", entries[0]) return dbSave("entries", entries[0])
} }
return Save("entries", entries) return dbSave("entries", entries)
} }
// EntryRemove deletes entries. Requires delete permission. // EntryRemove deletes entries. Requires delete permission.
@ -101,11 +101,11 @@ func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
} }
var entries []*Entry var entries []*Entry
if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil { if err := dbQuery("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil {
return err return err
} }
for _, e := range entries { for _, e := range entries {
if err := Delete("entries", "entry_id", e.EntryID); err != nil { if err := dbDelete("entries", "entry_id", e.EntryID); err != nil {
return err return err
} }
} }
@ -130,7 +130,7 @@ func EntryGet(ctx *AccessContext, id string) (*Entry, error) {
// entryGetRaw retrieves an entry without permission check (internal use only) // entryGetRaw retrieves an entry without permission check (internal use only)
func entryGetRaw(id string) (*Entry, error) { func entryGetRaw(id string) (*Entry, error) {
e := &Entry{} e := &Entry{}
return e, Load("entries", id, e) return e, dbLoad("entries", id, e)
} }
// EntryList retrieves entries. Requires read permission on parent/dossier. // EntryList retrieves entries. Requires read permission on parent/dossier.
@ -203,7 +203,7 @@ func EntryList(accessorID string, parent string, category int, f *EntryFilter) (
} }
var result []*Entry var result []*Entry
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -242,9 +242,9 @@ func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
} }
} }
if len(dossiers) == 1 { if len(dossiers) == 1 {
return Save("dossiers", dossiers[0]) return dbSave("dossiers", dossiers[0])
} }
return Save("dossiers", dossiers) return dbSave("dossiers", dossiers)
} }
// DossierRemove deletes dossiers. Requires manage permission. // DossierRemove deletes dossiers. Requires manage permission.
@ -271,7 +271,7 @@ func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
// dossierGetRaw retrieves a dossier without permission check (internal use only) // dossierGetRaw retrieves a dossier without permission check (internal use only)
func dossierGetRaw(id string) (*Dossier, error) { func dossierGetRaw(id string) (*Dossier, error) {
d := &Dossier{} d := &Dossier{}
if err := Load("dossiers", id, d); err != nil { if err := dbLoad("dossiers", id, d); err != nil {
return nil, err return nil, err
} }
// Parse DOB from encrypted string // Parse DOB from encrypted string
@ -306,7 +306,7 @@ func DossierList(ctx *AccessContext, f *DossierFilter) ([]*Dossier, error) {
} }
var result []*Dossier var result []*Dossier
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -323,7 +323,7 @@ func DossierGetByEmail(ctx *AccessContext, email string) (*Dossier, error) {
} }
q := "SELECT * FROM dossiers WHERE email = ? LIMIT 1" q := "SELECT * FROM dossiers WHERE email = ? LIMIT 1"
var result []*Dossier var result []*Dossier
if err := Query(q, []any{CryptoEncrypt(email)}, &result); err != nil { if err := dbQuery(q, []any{CryptoEncrypt(email)}, &result); err != nil {
return nil, err return nil, err
} }
if len(result) == 0 { if len(result) == 0 {
@ -339,7 +339,7 @@ func DossierGetBySessionToken(token string) *Dossier {
} }
q := "SELECT * FROM dossiers WHERE session_token = ? LIMIT 1" q := "SELECT * FROM dossiers WHERE session_token = ? LIMIT 1"
var result []*Dossier var result []*Dossier
if err := Query(q, []any{CryptoEncrypt(token)}, &result); err != nil { if err := dbQuery(q, []any{CryptoEncrypt(token)}, &result); err != nil {
return nil return nil
} }
if len(result) == 0 { if len(result) == 0 {
@ -367,9 +367,9 @@ func AccessWrite(records ...*DossierAccess) error {
} }
} }
if len(records) == 1 { if len(records) == 1 {
return Save("dossier_access", records[0]) return dbSave("dossier_access", records[0])
} }
return Save("dossier_access", records) return dbSave("dossier_access", records)
} }
func AccessRemove(accessorID, targetID string) error { func AccessRemove(accessorID, targetID string) error {
@ -377,13 +377,13 @@ func AccessRemove(accessorID, targetID string) error {
if err != nil { if err != nil {
return err return err
} }
return Delete("dossier_access", "access_id", access.AccessID) return dbDelete("dossier_access", "access_id", access.AccessID)
} }
func AccessGet(accessorID, targetID string) (*DossierAccess, error) { func AccessGet(accessorID, targetID string) (*DossierAccess, error) {
q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?" q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?"
var result []*DossierAccess var result []*DossierAccess
if err := Query(q, []any{accessorID, targetID}, &result); err != nil { if err := dbQuery(q, []any{accessorID, targetID}, &result); err != nil {
return nil, err return nil, err
} }
if len(result) == 0 { if len(result) == 0 {
@ -412,7 +412,7 @@ func AccessList(f *AccessFilter) ([]*DossierAccess, error) {
} }
var result []*DossierAccess var result []*DossierAccess
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -437,9 +437,9 @@ func AuditWrite(entries ...*AuditEntry) error {
} }
} }
if len(entries) == 1 { if len(entries) == 1 {
return Save("audit", entries[0]) return dbSave("audit", entries[0])
} }
return Save("audit", entries) return dbSave("audit", entries)
} }
func AuditList(f *AuditFilter) ([]*AuditEntry, error) { func AuditList(f *AuditFilter) ([]*AuditEntry, error) {
@ -476,7 +476,7 @@ func AuditList(f *AuditFilter) ([]*AuditEntry, error) {
} }
var result []*AuditEntry var result []*AuditEntry
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -500,9 +500,9 @@ func TrackerWrite(trackers ...*Tracker) error {
} }
} }
if len(trackers) == 1 { if len(trackers) == 1 {
return Save("trackers", trackers[0]) return dbSave("trackers", trackers[0])
} }
return Save("trackers", trackers) return dbSave("trackers", trackers)
} }
func TrackerRemove(ids ...string) error { func TrackerRemove(ids ...string) error {
@ -538,7 +538,7 @@ func TrackerList(f *TrackerFilter) ([]*Tracker, error) {
} }
var result []*Tracker var result []*Tracker
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -751,7 +751,7 @@ func AccessListByTargetWithNames(targetID string) ([]map[string]interface{}, err
// TrackerDistinctTypes returns distinct category/type pairs for a dossier's active trackers // TrackerDistinctTypes returns distinct category/type pairs for a dossier's active trackers
func TrackerDistinctTypes(dossierID string) (map[string][]string, error) { func TrackerDistinctTypes(dossierID string) (map[string][]string, error) {
var trackers []*Tracker var trackers []*Tracker
if err := Query("SELECT * FROM trackers WHERE dossier_id = ? AND active = 1", []any{dossierID}, &trackers); err != nil { if err := dbQuery("SELECT * FROM trackers WHERE dossier_id = ? AND active = 1", []any{dossierID}, &trackers); err != nil {
return nil, err return nil, err
} }
@ -791,15 +791,15 @@ func AccessGrantWrite(grants ...*Access) error {
} }
} }
if len(grants) == 1 { if len(grants) == 1 {
return Save("access", grants[0]) return dbSave("access", grants[0])
} }
return Save("access", grants) return dbSave("access", grants)
} }
// AccessGrantRemove removes access grants by ID // AccessGrantRemove removes access grants by ID
func AccessGrantRemove(ids ...string) error { func AccessGrantRemove(ids ...string) error {
for _, id := range ids { for _, id := range ids {
if err := Delete("access", "access_id", id); err != nil { if err := dbDelete("access", "access_id", id); err != nil {
return err return err
} }
} }
@ -814,7 +814,7 @@ func MigrateOldAccess() int {
CanEdit int `db:"can_edit"` CanEdit int `db:"can_edit"`
} }
var entries []oldAccess var entries []oldAccess
err := Query("SELECT accessor_dossier_id, target_dossier_id, can_edit FROM dossier_access WHERE status = 1", nil, &entries) err := dbQuery("SELECT accessor_dossier_id, target_dossier_id, can_edit FROM dossier_access WHERE status = 1", nil, &entries)
if err != nil { if err != nil {
return 0 return 0
} }
@ -852,7 +852,7 @@ func MigrateOldAccess() int {
func MigrateStudiesToCategoryRoot() int { func MigrateStudiesToCategoryRoot() int {
// Find all imaging entries with empty parent_id, filter to studies in Go // Find all imaging entries with empty parent_id, filter to studies in Go
var all []*Entry var all []*Entry
err := Query( err := dbQuery(
"SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')", "SELECT * FROM entries WHERE category = ? AND (parent_id IS NULL OR parent_id = '')",
[]any{CategoryImaging}, &all) []any{CategoryImaging}, &all)
if err != nil { if err != nil {
@ -882,7 +882,7 @@ func MigrateStudiesToCategoryRoot() int {
} }
s.ParentID = rootID s.ParentID = rootID
if err := Save("entries", s); err == nil { if err := dbSave("entries", s); err == nil {
migrated++ migrated++
} }
} }
@ -892,7 +892,7 @@ func MigrateStudiesToCategoryRoot() int {
// 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{}
return a, Load("access", id, a) return a, dbLoad("access", id, a)
} }
// AccessGrantList retrieves access grants with optional filtering // AccessGrantList retrieves access grants with optional filtering
@ -922,7 +922,7 @@ func AccessGrantList(f *PermissionFilter) ([]*Access, error) {
q += " ORDER BY created_at DESC" q += " ORDER BY created_at DESC"
var result []*Access var result []*Access
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -956,7 +956,7 @@ func AccessRoleTemplates(dossierID string) ([]*Access, error) {
q += " ORDER BY role, entry_id" q += " ORDER BY role, entry_id"
var result []*Access var result []*Access
err := Query(q, args, &result) err := dbQuery(q, args, &result)
return result, err return result, err
} }
@ -1003,7 +1003,7 @@ func AccessRevokeAll(dossierID, granteeID string) error {
return err return err
} }
for _, g := range grants { for _, g := range grants {
if err := Delete("access", "access_id", g.AccessID); err != nil { if err := dbDelete("access", "access_id", g.AccessID); err != nil {
return err return err
} }
} }
@ -1017,7 +1017,7 @@ func AccessRevokeEntry(dossierID, granteeID, entryID string) error {
return err return err
} }
for _, g := range grants { for _, g := range grants {
if err := Delete("access", "access_id", g.AccessID); err != nil { if err := dbDelete("access", "access_id", g.AccessID); err != nil {
return err return err
} }
} }
@ -1209,10 +1209,14 @@ type GenomeQueryOpts struct {
AccessorID string // who is querying (for audit logging) AccessorID string // who is querying (for audit logging)
} }
// GenomeQuery queries genome variants for a dossier. // GenomeQuery queries genome variants for a dossier. Requires read permission on genome data.
// Fast path: gene/rsid use indexed search_key/type columns (precise SQL queries). // Fast path: gene/rsid use indexed search_key/type columns (precise SQL queries).
// Slow path: search/min_magnitude load all variants and filter in memory. // Slow path: search/min_magnitude load all variants and filter in memory.
func GenomeQuery(dossierID string, opts GenomeQueryOpts) (*GenomeQueryResult, error) { func GenomeQuery(ctx *AccessContext, dossierID string, opts GenomeQueryOpts) (*GenomeQueryResult, error) {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", CategoryGenome, 'r'); err != nil {
return nil, err
}
if opts.IncludeHidden { if opts.IncludeHidden {
var details []string var details []string
if opts.Gene != "" { if opts.Gene != "" {
@ -1277,7 +1281,7 @@ func genomeQueryFast(dossierID string, opts GenomeQueryOpts, limit int) (*Genome
sql += " AND type IN (" + strings.Join(rsidPlaceholders, ",") + ")" sql += " AND type IN (" + strings.Join(rsidPlaceholders, ",") + ")"
} }
Query(sql, args, &entries) dbQuery(sql, args, &entries)
} else if len(opts.RSIDs) > 0 { } else if len(opts.RSIDs) > 0 {
// rsid only, no gene — single IN query // rsid only, no gene — single IN query
placeholders := make([]string, len(opts.RSIDs)) placeholders := make([]string, len(opts.RSIDs))
@ -1287,7 +1291,7 @@ func genomeQueryFast(dossierID string, opts GenomeQueryOpts, limit int) (*Genome
args = append(args, CryptoEncrypt(rsid)) args = append(args, CryptoEncrypt(rsid))
} }
sql := "SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND type IN (" + strings.Join(placeholders, ",") + ")" sql := "SELECT * FROM entries WHERE dossier_id = ? AND category = ? AND type IN (" + strings.Join(placeholders, ",") + ")"
Query(sql, args, &entries) dbQuery(sql, args, &entries)
} }
// Look up tier categories for parent_ids (single IN query) // Look up tier categories for parent_ids (single IN query)
@ -1304,7 +1308,7 @@ func genomeQueryFast(dossierID string, opts GenomeQueryOpts, limit int) (*Genome
args = append(args, id) args = append(args, id)
} }
var tierEntries []Entry var tierEntries []Entry
Query("SELECT * FROM entries WHERE entry_id IN ("+strings.Join(placeholders, ",")+")", args, &tierEntries) dbQuery("SELECT * FROM entries WHERE entry_id IN ("+strings.Join(placeholders, ",")+")", args, &tierEntries)
for _, t := range tierEntries { for _, t := range tierEntries {
tierCategories[t.EntryID] = t.Value tierCategories[t.EntryID] = t.Value
} }
@ -1499,11 +1503,81 @@ func genomeEntriesToResult(entries []Entry, tierCategories map[string]string, op
}, nil }, nil
} }
// --- RBAC-CHECKED QUERY HELPERS ---
// EntryCategoryCounts returns entry counts by category for a dossier.
func EntryCategoryCounts(ctx *AccessContext, dossierID string) (map[string]int, error) {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'r'); err != nil {
return nil, err
}
var counts []struct {
Category int `db:"category"`
Count int `db:"cnt"`
}
if err := dbQuery("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? AND category > 0 GROUP BY category", []any{dossierID}, &counts); err != nil {
return nil, err
}
result := make(map[string]int)
for _, c := range counts {
name := CategoryName(c.Category)
if name != "unknown" {
result[name] = c.Count
}
}
return result, nil
}
// EntryCount returns entry count for a dossier by category and optional type.
func EntryCount(ctx *AccessContext, dossierID string, category int, typ string) (int, error) {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", category, 'r'); err != nil {
return 0, err
}
if typ != "" {
return dbCount("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?",
dossierID, category, CryptoEncrypt(typ))
}
return dbCount("SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?",
dossierID, category)
}
// EntryListByDossier returns all entries for a dossier ordered by category and timestamp.
func EntryListByDossier(ctx *AccessContext, dossierID string) ([]*Entry, error) {
if err := checkAccess(accessorIDFromContext(ctx), dossierID, "", 0, 'r'); err != nil {
return nil, err
}
var entries []*Entry
return entries, dbQuery("SELECT * FROM entries WHERE dossier_id = ? ORDER BY category, timestamp", []any{dossierID}, &entries)
}
// LabTestList returns all lab tests (reference data, no RBAC needed).
func LabTestList() ([]LabTest, error) {
var tests []LabTest
return tests, dbQuery("SELECT loinc_id, name FROM lab_test", nil, &tests)
}
// LabEntryListForIndex returns lab entries with data for building search indexes.
func LabEntryListForIndex() ([]*Entry, error) {
var entries []*Entry
return entries, dbQuery("SELECT entry_id, data FROM entries WHERE category = ? AND parent_id != ''",
[]any{CategoryLab}, &entries)
}
// LabRefListBySource returns lab references matching a source pattern in ref_id.
func LabRefListBySource(source string) ([]LabReference, error) {
var refs []LabReference
return refs, dbQuery("SELECT * FROM lab_reference WHERE ref_id LIKE ?", []any{"%|" + source + "|%"}, &refs)
}
// LabRefDeleteByID deletes a lab reference by ref_id.
func LabRefDeleteByID(refID string) error {
return dbDelete("lab_reference", "ref_id", refID)
}
// --- HELPERS --- // --- HELPERS ---
func deleteByIDs(table, col string, ids []string) error { func deleteByIDs(table, col string, ids []string) error {
for _, id := range ids { for _, id := range ids {
if err := Delete(table, col, id); err != nil { if err := dbDelete(table, col, id); err != nil {
return err return err
} }
} }

View File

@ -492,8 +492,8 @@ func entriesToSectionItems(entries []*lib.Entry) []SectionItem {
// buildLoincNameMap builds a JSON map of LOINC code → full test name // buildLoincNameMap builds a JSON map of LOINC code → full test name
// for displaying full names in charts. // for displaying full names in charts.
func buildLoincNameMap() string { func buildLoincNameMap() string {
var tests []lib.LabTest tests, err := lib.LabTestList()
if err := lib.Query("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil { if err != nil {
return "{}" return "{}"
} }
@ -510,8 +510,8 @@ func buildLoincNameMap() string {
// buildLabSearchIndex builds a JSON map of search terms → LOINC codes // buildLabSearchIndex builds a JSON map of search terms → LOINC codes
// for client-side lab result filtering. Keys are lowercase test names and abbreviations. // for client-side lab result filtering. Keys are lowercase test names and abbreviations.
func buildLabSearchIndex() string { func buildLabSearchIndex() string {
var tests []lib.LabTest tests, err := lib.LabTestList()
if err := lib.Query("SELECT loinc_id, name FROM lab_test", nil, &tests); err != nil { if err != nil {
return "{}" return "{}"
} }
@ -542,9 +542,8 @@ func buildLabSearchIndex() string {
// Also index by abbreviations from actual lab entries // Also index by abbreviations from actual lab entries
// Get unique LOINC+abbreviation pairs from all lab entries with parent // Get unique LOINC+abbreviation pairs from all lab entries with parent
var entries []*lib.Entry entries, err2 := lib.LabEntryListForIndex()
if err := lib.Query("SELECT entry_id, data FROM entries WHERE category = ? AND parent_id != ''", if err2 == nil {
[]any{lib.CategoryLab}, &entries); err == nil {
seen := make(map[string]bool) // Track LOINC+abbr pairs to avoid duplicates seen := make(map[string]bool) // Track LOINC+abbr pairs to avoid duplicates
for _, e := range entries { for _, e := range entries {
var data struct { var data struct {

View File

@ -218,7 +218,7 @@ func processGenomeUpload(uploadID string, dossierID string, filePath string) {
} }
// Delete existing genome entries (all genome data uses CategoryGenome with different Types) // Delete existing genome entries (all genome data uses CategoryGenome with different Types)
lib.EntryDeleteByCategory(dossierID, lib.CategoryGenome) lib.EntryDeleteByCategory(nil, dossierID, lib.CategoryGenome) // nil ctx = internal genome processing
// Create extraction entry (tier 1) // Create extraction entry (tier 1)
now := time.Now().Unix() now := time.Now().Unix()

View File

@ -850,28 +850,15 @@ func handleInvite(w http.ResponseWriter, r *http.Request) {
} }
func getDossierStats(dossierID string) DossierStats { func getDossierStats(dossierID string) DossierStats {
ctx := &lib.AccessContext{AccessorID: dossierID} // Self-access for dashboard
var stats DossierStats var stats DossierStats
// Count studies (not slices/series) stats.Imaging, _ = lib.EntryCount(ctx, dossierID, lib.CategoryImaging, "study")
stats.Imaging, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`, stats.Labs, _ = lib.EntryCount(ctx, dossierID, lib.CategoryLab, "lab_report")
dossierID, lib.CategoryImaging, lib.CryptoEncrypt("study")) stats.Genome, _ = lib.EntryCount(ctx, dossierID, lib.CategoryGenome, "tier")
// Count lab reports stats.Documents, _ = lib.EntryCount(ctx, dossierID, lib.CategoryDocument, "")
stats.Labs, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`, stats.Vitals, _ = lib.EntryCount(ctx, dossierID, lib.CategoryVital, "")
dossierID, lib.CategoryLab, lib.CryptoEncrypt("lab_report")) stats.Medications, _ = lib.EntryCount(ctx, dossierID, lib.CategoryMedication, "")
// Check if genome data exists (count tiers) stats.Supplements, _ = lib.EntryCount(ctx, dossierID, lib.CategorySupplement, "")
stats.Genome, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ? AND type = ?`,
dossierID, lib.CategoryGenome, lib.CryptoEncrypt("tier"))
// Documents
stats.Documents, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryDocument)
// Vitals
stats.Vitals, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryVital)
// Medications
stats.Medications, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategoryMedication)
// Supplements
stats.Supplements, _ = lib.Count(`SELECT COUNT(*) FROM entries WHERE dossier_id = ? AND category = ?`,
dossierID, lib.CategorySupplement)
return stats return stats
} }
@ -1189,8 +1176,7 @@ func handleExportData(w http.ResponseWriter, r *http.Request) {
if err != nil || dossier == nil { http.NotFound(w, r); return } if err != nil || dossier == nil { http.NotFound(w, r); return }
// Get ALL entries for this dossier (including nested) // Get ALL entries for this dossier (including nested)
var entries []*lib.Entry entries, _ := lib.EntryListByDossier(nil, targetID) // nil ctx = internal export operation
lib.Query("SELECT * FROM entries WHERE dossier_id = ? ORDER BY category, timestamp", []any{targetID}, &entries)
// Build clean export structure (no IDs) // Build clean export structure (no IDs)
type ExportDossier struct { type ExportDossier struct {

View File

@ -614,7 +614,7 @@ func handleMCPToolsCall(w http.ResponseWriter, req mcpRequest, accessToken, doss
sendMCPError(w, req.ID, -32602, "dossier required") sendMCPError(w, req.ID, -32602, "dossier required")
return return
} }
result, err := mcpGetCategories(dossier) result, err := mcpGetCategories(dossier, dossierID)
if err != nil { if err != nil {
sendMCPError(w, req.ID, -32000, err.Error()) sendMCPError(w, req.ID, -32000, err.Error())
return return

View File

@ -181,27 +181,18 @@ func mcpQueryEntries(accessToken, dossier, category, typ, searchKey, parent, fro
return string(pretty), nil return string(pretty), nil
} }
func mcpGetCategories(dossier string) (string, error) { func mcpGetCategories(dossier, accessorID string) (string, error) {
var counts []struct { ctx := &lib.AccessContext{AccessorID: accessorID}
Category int `db:"category"` result, err := lib.EntryCategoryCounts(ctx, dossier)
Count int `db:"cnt"`
}
err := lib.Query("SELECT category, COUNT(*) as cnt FROM entries WHERE dossier_id = ? GROUP BY category", []any{dossier}, &counts)
if err != nil { if err != nil {
return "", err return "", err
} }
result := make(map[string]int)
for _, c := range counts {
name := lib.CategoryName(c.Category)
if name != "unknown" {
result[name] = c.Count
}
}
pretty, _ := json.MarshalIndent(result, "", " ") pretty, _ := json.MarshalIndent(result, "", " ")
return string(pretty), nil return string(pretty), nil
} }
func mcpQueryGenome(accessToken, dossier, accessorID, gene, search, category, rsids string, minMag float64, repute string, includeHidden bool, limit, offset int) (string, error) { func mcpQueryGenome(accessToken, dossier, accessorID, gene, search, category, rsids string, minMag float64, repute string, includeHidden bool, limit, offset int) (string, error) {
ctx := &lib.AccessContext{AccessorID: accessorID}
var rsidList []string var rsidList []string
if rsids != "" { if rsids != "" {
rsidList = strings.Split(rsids, ",") rsidList = strings.Split(rsids, ",")
@ -218,7 +209,7 @@ func mcpQueryGenome(accessToken, dossier, accessorID, gene, search, category, rs
limit = 20 * numTerms limit = 20 * numTerms
} }
result, err := lib.GenomeQuery(dossier, lib.GenomeQueryOpts{ result, err := lib.GenomeQuery(ctx, dossier, lib.GenomeQueryOpts{
Category: category, Category: category,
Search: search, Search: search,
Gene: gene, Gene: gene,

View File

@ -211,7 +211,7 @@ func handleUploadPost(w http.ResponseWriter, r *http.Request) {
// Delete existing upload with same filename (re-upload cleanup) // Delete existing upload with same filename (re-upload cleanup)
existingUploads := findUploadByFilename(targetID, fileName) existingUploads := findUploadByFilename(targetID, fileName)
for _, old := range existingUploads { for _, old := range existingUploads {
lib.EntryDeleteTree(targetID, old.EntryID) lib.EntryDeleteTree(nil, targetID, old.EntryID) // nil ctx = internal upload cleanup
} }
now := time.Now().Unix() now := time.Now().Unix()

View File

@ -133,6 +133,38 @@ else
echo -e "${GREEN}OK${NC}" echo -e "${GREEN}OK${NC}"
fi fi
echo ""
echo "=== Unexported DB Function Check ==="
echo ""
# Now that Query/Save/Load/Delete/Count are unexported (dbQuery, dbSave, etc.),
# no code outside lib/ should reference them. The Go compiler enforces this,
# but this check catches it earlier (before build).
ALL_DIRS="portal api viewer mcp-client import-genome cmd find_dossiers tools test-prompts doc-processor"
for fn in "lib\.Query(" "lib\.Save(" "lib\.Load(" "lib\.Delete(" "lib\.Count("; do
name=$(echo "$fn" | sed 's/lib\\.//;s/($//')
echo -n "Checking for $fn outside lib/... "
MATCHES=""
for dir in $ALL_DIRS; do
if [ -d "$dir" ]; then
FOUND=$(grep -rn "$fn" --include="*.go" "$dir" 2>/dev/null || true)
# Filter out comments and string literals (rough heuristic: lines with // before the match)
if [ -n "$FOUND" ]; then
REAL=$(echo "$FOUND" | grep -v "^\s*//" | grep -v "^\s*\*" || true)
MATCHES="$MATCHES$REAL"
fi
fi
done
if [ -n "$MATCHES" ]; then
echo -e "${RED}FAILED${NC}"
echo "$MATCHES"
echo " lib.$name() is unexported — use RBAC-checked functions instead"
ERRORS=$((ERRORS + 1))
else
echo -e "${GREEN}OK${NC}"
fi
done
echo "" echo ""
echo "=== RBAC Enforcement Check ===" echo "=== RBAC Enforcement Check ==="
echo "" echo ""
@ -191,7 +223,7 @@ else
echo -e "${RED}$ERRORS check(s) failed!${NC}" echo -e "${RED}$ERRORS check(s) failed!${NC}"
echo "" echo ""
echo "Direct database access is FORBIDDEN without Johan's express consent." echo "Direct database access is FORBIDDEN without Johan's express consent."
echo "All DB operations must go through lib/db_queries.go functions:" echo "All DB operations must go through RBAC-checked lib functions."
echo " - Save(), Load(), Query(), Delete(), Count()" echo "Raw DB functions (dbQuery, dbSave, etc.) are unexported — only callable from lib/."
exit 1 exit 1
fi fi

View File

@ -137,7 +137,7 @@ func main() {
// --- Local Prompt Handling Functions --- // --- Local Prompt Handling Functions ---
func loadPrompt(name string) (string, error) { func loadPrompt(name string) (string, error) {
path := filepath.Join(lib.PromptsDir(), name+".md") path := filepath.Join(lib.TrackerPromptsDir(), name+".md")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return "", err return "", err

View File

@ -70,14 +70,13 @@ func main() {
// Delete existing CALIPER references // Delete existing CALIPER references
if !dryRun { if !dryRun {
// Query existing to count existing, err := lib.LabRefListBySource("CALIPER")
var existing []lib.LabReference if err != nil {
if err := lib.Query("SELECT ref_id FROM lab_reference WHERE ref_id LIKE '%|CALIPER|%'", nil, &existing); err != nil {
log.Printf("Warning: could not count existing: %v", err) log.Printf("Warning: could not count existing: %v", err)
} else { } else {
log.Printf("Deleting %d existing CALIPER references", len(existing)) log.Printf("Deleting %d existing CALIPER references", len(existing))
for _, r := range existing { for _, r := range existing {
lib.Delete("lab_reference", "ref_id", r.RefID) lib.LabRefDeleteByID(r.RefID)
} }
} }
} }
@ -206,11 +205,8 @@ func main() {
aliasTotal := 0 aliasTotal := 0
for _, pair := range aliases { for _, pair := range aliases {
wrong, correct := pair[0], pair[1] wrong, correct := pair[0], pair[1]
var srcRefs []lib.LabReference srcRefs, err := lib.LabRefLookupAll(correct)
if err := lib.Query( if err != nil || len(srcRefs) == 0 {
"SELECT ref_id, loinc_id, source, sex, age_days, age_end, ref_low, ref_high, unit FROM lab_reference WHERE loinc_id = ?",
[]any{correct}, &srcRefs,
); err != nil || len(srcRefs) == 0 {
continue continue
} }
var copies []lib.LabReference var copies []lib.LabReference