inou/lib/v2.go

1089 lines
26 KiB
Go

package lib
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"time"
)
// ============================================================================
// V2 API - Clean data layer using Store
// ============================================================================
//
// RBAC ENFORCEMENT:
// All data access functions take AccessContext as first parameter.
// Permission checks happen HERE at the lowest level - there is NO WAY to bypass.
//
// Rules:
// - ctx == nil or ctx.IsSystem → allow (for internal/system operations)
// - ctx.AccessorID == dossierID → allow (owner)
// - Otherwise → check access grants in database
//
// ============================================================================
// --- ENTRY ---
type EntryFilter struct {
DossierID string
Type string
Value string
FromDate int64
ToDate int64
Limit int
}
// EntryWrite creates/updates entries. Requires write permission on parent.
func EntryWrite(ctx *AccessContext, entries ...*Entry) error {
if len(entries) == 0 {
return nil
}
// RBAC: Check write permission for each entry
for _, e := range entries {
if e.DossierID == "" {
return fmt.Errorf("entry missing dossier_id")
}
// Check write on parent (or root if no parent)
if err := checkAccess(ctx, e.DossierID, e.ParentID, 'w'); err != nil {
return err
}
}
for _, e := range entries {
if e.EntryID == "" {
e.EntryID = NewID()
}
}
if len(entries) == 1 {
return Save("entries", entries[0])
}
return Save("entries", entries)
}
// EntryRemove deletes entries. Requires delete permission.
func EntryRemove(ctx *AccessContext, ids ...string) error {
// RBAC: Check delete permission for each entry
for _, id := range ids {
e, err := entryGetRaw(id)
if err != nil {
continue // Entry doesn't exist, skip
}
if err := checkAccess(ctx, e.DossierID, id, 'd'); err != nil {
return err
}
}
return deleteByIDs("entries", "entry_id", ids)
}
// EntryRemoveByDossier removes all entries for a dossier. Requires delete permission on dossier root.
func EntryRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root
if err := checkAccess(ctx, dossierID, "", 'd'); err != nil {
return err
}
var entries []*Entry
if err := Query("SELECT entry_id FROM entries WHERE dossier_id = ?", []any{dossierID}, &entries); err != nil {
return err
}
for _, e := range entries {
if err := Delete("entries", "entry_id", e.EntryID); err != nil {
return err
}
}
return nil
}
// EntryGet retrieves an entry. Requires read permission.
func EntryGet(ctx *AccessContext, id string) (*Entry, error) {
e, err := entryGetRaw(id)
if err != nil {
return nil, err
}
// RBAC: Check read permission
if err := checkAccess(ctx, e.DossierID, id, 'r'); err != nil {
return nil, err
}
return e, nil
}
// entryGetRaw retrieves an entry without permission check (internal use only)
func entryGetRaw(id string) (*Entry, error) {
e := &Entry{}
return e, Load("entries", id, e)
}
// EntryList retrieves entries. Requires read permission on parent/dossier.
func EntryList(ctx *AccessContext, parent string, category int, f *EntryFilter) ([]*Entry, error) {
// RBAC: Determine dossier and check read permission
dossierID := ""
if f != nil {
dossierID = f.DossierID
}
if dossierID == "" && parent != "" {
// Get dossier from parent entry
if p, err := entryGetRaw(parent); err == nil {
dossierID = p.DossierID
}
}
if dossierID != "" {
if err := checkAccess(ctx, dossierID, parent, 'r'); err != nil {
return nil, err
}
}
q := "SELECT * FROM entries WHERE 1=1"
args := []any{}
if category > 0 {
q += " AND category = ?"
args = append(args, category)
}
if parent == "" {
q += " AND (parent_id IS NULL OR parent_id = '')"
} else {
q += " AND parent_id = ?"
args = append(args, parent)
}
if f != nil {
if f.DossierID != "" {
q += " AND dossier_id = ?"
args = append(args, f.DossierID)
}
if f.Type != "" {
q += " AND type = ?"
args = append(args, CryptoEncrypt(f.Type))
}
if f.Value != "" {
q += " AND value = ?"
args = append(args, CryptoEncrypt(f.Value))
}
if f.FromDate > 0 {
q += " AND timestamp >= ?"
args = append(args, f.FromDate)
}
if f.ToDate > 0 {
q += " AND timestamp < ?"
args = append(args, f.ToDate)
}
}
q += " ORDER BY ordinal"
if f != nil && f.Limit > 0 {
q += fmt.Sprintf(" LIMIT %d", f.Limit)
}
var result []*Entry
err := Query(q, args, &result)
return result, err
}
// --- DOSSIER ---
type DossierFilter struct {
EmailHash string
DateOfBirth string
Limit int
}
// DossierWrite creates/updates dossiers. Requires manage permission (or nil ctx for system).
func DossierWrite(ctx *AccessContext, dossiers ...*Dossier) error {
if len(dossiers) == 0 {
return nil
}
// RBAC: For existing dossiers, check manage permission
for _, d := range dossiers {
if d.DossierID != "" {
// Update - need manage permission (unless creating own or system)
if err := checkAccess(ctx, d.DossierID, "", 'm'); err != nil {
return err
}
}
// New dossiers (no ID) are allowed - they'll get assigned an ID
}
for _, d := range dossiers {
if d.DossierID == "" {
d.DossierID = NewID()
}
// Format DOB to encrypted string
if !d.DOB.IsZero() {
d.DateOfBirth = d.DOB.Format("2006-01-02")
}
}
if len(dossiers) == 1 {
return Save("dossiers", dossiers[0])
}
return Save("dossiers", dossiers)
}
// DossierRemove deletes dossiers. Requires manage permission.
func DossierRemove(ctx *AccessContext, ids ...string) error {
// RBAC: Check manage permission for each dossier
for _, id := range ids {
if err := checkAccess(ctx, id, "", 'm'); err != nil {
return err
}
}
return deleteByIDs("dossiers", "dossier_id", ids)
}
// DossierGet retrieves a dossier. Requires read permission.
func DossierGet(ctx *AccessContext, id string) (*Dossier, error) {
// RBAC: Check read permission
if err := checkAccess(ctx, id, "", 'r'); err != nil {
return nil, err
}
return dossierGetRaw(id)
}
// dossierGetRaw retrieves a dossier without permission check (internal use only)
func dossierGetRaw(id string) (*Dossier, error) {
d := &Dossier{}
if err := Load("dossiers", id, d); err != nil {
return nil, err
}
// Parse DOB from encrypted string
if d.DateOfBirth != "" {
d.DOB, _ = time.Parse("2006-01-02", d.DateOfBirth)
}
return d, nil
}
// DossierList retrieves dossiers. System only (for security - lists all dossiers).
func DossierList(ctx *AccessContext, f *DossierFilter) ([]*Dossier, error) {
// RBAC: Only system context can list all dossiers
if ctx != nil && !ctx.IsSystem {
return nil, ErrAccessDenied
}
q := "SELECT * FROM dossiers WHERE 1=1"
args := []any{}
if f != nil {
if f.EmailHash != "" {
q += " AND email_hash = ?"
args = append(args, f.EmailHash)
}
if f.DateOfBirth != "" {
q += " AND date_of_birth = ?"
args = append(args, CryptoEncrypt(f.DateOfBirth))
}
if f.Limit > 0 {
q += fmt.Sprintf(" LIMIT %d", f.Limit)
}
}
var result []*Dossier
err := Query(q, args, &result)
return result, err
}
// DossierGetByEmail retrieves a dossier by email. System only (auth flow).
func DossierGetByEmail(ctx *AccessContext, email string) (*Dossier, error) {
// RBAC: Only system context can lookup by email (for auth)
if ctx != nil && !ctx.IsSystem {
return nil, ErrAccessDenied
}
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return nil, fmt.Errorf("empty email")
}
q := "SELECT * FROM dossiers WHERE email = ? LIMIT 1"
var result []*Dossier
if err := Query(q, []any{CryptoEncrypt(email)}, &result); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, fmt.Errorf("not found")
}
return result[0], nil
}
// DossierGetBySessionToken retrieves a dossier by session token. No RBAC (auth flow).
func DossierGetBySessionToken(token string) *Dossier {
if token == "" {
return nil
}
q := "SELECT * FROM dossiers WHERE session_token = ? LIMIT 1"
var result []*Dossier
if err := Query(q, []any{CryptoEncrypt(token)}, &result); err != nil {
return nil
}
if len(result) == 0 {
return nil
}
return result[0]
}
// --- LEGACY ACCESS (dossier_access table) ---
// TODO: Migrate to new RBAC access table
type AccessFilter struct {
AccessorID string
TargetID string
Status *int
}
func AccessWrite(records ...*DossierAccess) error {
if len(records) == 0 {
return nil
}
for _, r := range records {
if r.AccessID == "" {
r.AccessID = NewID()
}
}
if len(records) == 1 {
return Save("dossier_access", records[0])
}
return Save("dossier_access", records)
}
func AccessRemove(accessorID, targetID string) error {
access, err := AccessGet(accessorID, targetID)
if err != nil {
return err
}
return Delete("dossier_access", "access_id", access.AccessID)
}
func AccessGet(accessorID, targetID string) (*DossierAccess, error) {
q := "SELECT * FROM dossier_access WHERE accessor_dossier_id = ? AND target_dossier_id = ?"
var result []*DossierAccess
if err := Query(q, []any{accessorID, targetID}, &result); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, fmt.Errorf("not found")
}
return result[0], nil
}
func AccessList(f *AccessFilter) ([]*DossierAccess, error) {
q := "SELECT * FROM dossier_access WHERE 1=1"
args := []any{}
if f != nil {
if f.AccessorID != "" {
q += " AND accessor_dossier_id = ?"
args = append(args, f.AccessorID)
}
if f.TargetID != "" {
q += " AND target_dossier_id = ?"
args = append(args, f.TargetID)
}
if f.Status != nil {
q += " AND status = ?"
args = append(args, *f.Status)
}
}
var result []*DossierAccess
err := Query(q, args, &result)
return result, err
}
// --- AUDIT ---
type AuditFilter struct {
ActorID string
TargetID string
Action string
FromDate int64
ToDate int64
Limit int
}
func AuditWrite(entries ...*AuditEntry) error {
if len(entries) == 0 {
return nil
}
for _, a := range entries {
if a.AuditID == "" {
a.AuditID = NewID()
}
}
if len(entries) == 1 {
return Save("audit", entries[0])
}
return Save("audit", entries)
}
func AuditList(f *AuditFilter) ([]*AuditEntry, error) {
q := "SELECT * FROM audit WHERE 1=1"
args := []any{}
if f != nil {
if f.ActorID != "" {
q += " AND actor1_id = ?"
args = append(args, f.ActorID)
}
if f.TargetID != "" {
q += " AND target_id = ?"
args = append(args, f.TargetID)
}
if f.Action != "" {
q += " AND action = ?"
args = append(args, CryptoEncrypt(f.Action))
}
if f.FromDate > 0 {
q += " AND timestamp >= ?"
args = append(args, f.FromDate)
}
if f.ToDate > 0 {
q += " AND timestamp < ?"
args = append(args, f.ToDate)
}
}
q += " ORDER BY timestamp DESC"
if f != nil && f.Limit > 0 {
q += fmt.Sprintf(" LIMIT %d", f.Limit)
}
var result []*AuditEntry
err := Query(q, args, &result)
return result, err
}
// --- PROMPT ---
type PromptFilter struct {
DossierID string
Category string
Type string
ActiveOnly bool
Limit int
}
func PromptWrite(prompts ...*Prompt) error {
if len(prompts) == 0 {
return nil
}
for _, p := range prompts {
if p.PromptID == "" {
p.PromptID = NewID()
}
}
if len(prompts) == 1 {
return Save("prompts", prompts[0])
}
return Save("prompts", prompts)
}
func PromptRemove(ids ...string) error {
return deleteByIDs("prompts", "prompt_id", ids)
}
func PromptList(f *PromptFilter) ([]*Prompt, error) {
q := "SELECT * FROM prompts WHERE 1=1"
args := []any{}
if f != nil {
if f.DossierID != "" {
q += " AND dossier_id = ?"
args = append(args, f.DossierID)
}
if f.Category != "" {
q += " AND category = ?"
args = append(args, CryptoEncrypt(f.Category))
}
if f.Type != "" {
q += " AND type = ?"
args = append(args, CryptoEncrypt(f.Type))
}
if f.ActiveOnly {
q += " AND active = 1 AND dismissed = 0"
}
}
q += " ORDER BY active DESC, time_of_day, created_at"
if f != nil && f.Limit > 0 {
q += fmt.Sprintf(" LIMIT %d", f.Limit)
}
var result []*Prompt
err := Query(q, args, &result)
return result, err
}
// --- IMAGE ---
type ImageOpts struct {
WC, WW float64 // window center/width (0 = defaults)
Zoom float64 // unused
PanX, PanY float64 // unused
}
// ImageGet retrieves an image. Requires read permission.
func ImageGet(ctx *AccessContext, id string, opts *ImageOpts) ([]byte, error) {
e, err := entryGetRaw(id)
if err != nil {
return nil, err
}
// RBAC: Check read permission
if err := checkAccess(ctx, e.DossierID, id, 'r'); err != nil {
return nil, err
}
var data struct {
WindowCenter float64 `json:"window_center"`
WindowWidth float64 `json:"window_width"`
PixelMin int `json:"pixel_min"`
PixelMax int `json:"pixel_max"`
RescaleSlope float64 `json:"rescale_slope"`
RescaleIntercept float64 `json:"rescale_intercept"`
}
json.Unmarshal([]byte(e.Data), &data)
slope := data.RescaleSlope
if slope == 0 {
slope = 1
}
center, width := data.WindowCenter, data.WindowWidth
if data.RescaleIntercept != 0 {
center = (center - data.RescaleIntercept) / slope
width = width / slope
}
if center == 0 && width == 0 {
center = float64(data.PixelMin+data.PixelMax) / 2
width = float64(data.PixelMax - data.PixelMin)
if width == 0 {
width = 1
}
}
if opts != nil {
if opts.WC != 0 {
center = opts.WC
}
if opts.WW != 0 {
width = opts.WW
}
}
dec, err := objectReadRaw(e.DossierID, id)
if err != nil {
return nil, err
}
img, err := png.Decode(bytes.NewReader(dec))
if err != nil {
return nil, err
}
g16, ok := img.(*image.Gray16)
if !ok {
return nil, fmt.Errorf("not 16-bit grayscale")
}
low, high := center-width/2, center+width/2
lut := make([]uint8, 65536)
for i := range lut {
if float64(i) <= low {
lut[i] = 0
} else if float64(i) >= high {
lut[i] = 255
} else {
lut[i] = uint8((float64(i) - low) * 255 / width)
}
}
bounds := g16.Bounds()
out := image.NewGray(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
out.SetGray(x, y, color.Gray{Y: lut[g16.Gray16At(x, y).Y]})
}
}
var buf bytes.Buffer
png.Encode(&buf, out)
return buf.Bytes(), nil
}
// --- OBJECT STORAGE ---
// ObjectWrite encrypts and writes data to the object store. Requires write permission.
func ObjectWrite(ctx *AccessContext, dossierID, entryID string, data []byte) error {
// RBAC: Check write permission
if err := checkAccess(ctx, dossierID, entryID, 'w'); err != nil {
return err
}
path := ObjectPath(dossierID, entryID)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
encrypted := CryptoEncryptBytes(data)
return os.WriteFile(path, encrypted, 0644)
}
// ObjectRead reads and decrypts data from the object store. Requires read permission.
func ObjectRead(ctx *AccessContext, dossierID, entryID string) ([]byte, error) {
// RBAC: Check read permission
if err := checkAccess(ctx, dossierID, entryID, 'r'); err != nil {
return nil, err
}
raw, err := os.ReadFile(ObjectPath(dossierID, entryID))
if err != nil {
return nil, err
}
return CryptoDecryptBytes(raw)
}
// objectReadRaw reads object without permission check (internal use only, e.g. ImageGet)
func objectReadRaw(dossierID, entryID string) ([]byte, error) {
raw, err := os.ReadFile(ObjectPath(dossierID, entryID))
if err != nil {
return nil, err
}
return CryptoDecryptBytes(raw)
}
// ObjectRemove deletes an object from the store. Requires delete permission.
func ObjectRemove(ctx *AccessContext, dossierID, entryID string) error {
// RBAC: Check delete permission
if err := checkAccess(ctx, dossierID, entryID, 'd'); err != nil {
return err
}
return os.Remove(ObjectPath(dossierID, entryID))
}
// ObjectRemoveByDossier removes all objects for a dossier. Requires delete permission.
func ObjectRemoveByDossier(ctx *AccessContext, dossierID string) error {
// RBAC: Check delete permission on dossier root
if err := checkAccess(ctx, dossierID, "", 'd'); err != nil {
return err
}
return os.RemoveAll(filepath.Join(ObjectDir, dossierID))
}
// DossierListAccessible is now in access.go with RBAC enforcement
// DossierGetByEmailHash returns a dossier by email_hash. System only.
func DossierGetByEmailHash(ctx *AccessContext, emailHash string) (*Dossier, error) {
dossiers, err := DossierList(ctx, &DossierFilter{EmailHash: emailHash, Limit: 1})
if err != nil {
return nil, err
}
if len(dossiers) == 0 {
return nil, fmt.Errorf("not found")
}
return dossiers[0], nil
}
// DossierGetFirst returns the first dossier (for dev/test purposes only). System only.
func DossierGetFirst(ctx *AccessContext) (*Dossier, error) {
dossiers, err := DossierList(ctx, &DossierFilter{Limit: 1})
if err != nil {
return nil, err
}
if len(dossiers) == 0 {
return nil, fmt.Errorf("no dossiers")
}
return dossiers[0], nil
}
// AccessListByTargetWithNames returns access records with accessor names joined from dossiers
func AccessListByTargetWithNames(targetID string) ([]map[string]interface{}, error) {
// Get access records via store.go
accessList, err := AccessList(&AccessFilter{TargetID: targetID})
if err != nil {
return nil, err
}
var result []map[string]interface{}
for _, a := range accessList {
name := ""
if d, err := DossierGet(nil, a.AccessorDossierID); err == nil && d != nil {
name = d.Name
}
result = append(result, map[string]interface{}{
"accessor_id": a.AccessorDossierID,
"name": name,
"relation": a.Relation,
"is_care_receiver": a.IsCareReceiver,
"can_edit": a.CanEdit,
})
}
return result, nil
}
// PromptDistinctTypes returns distinct category/type pairs for a dossier's active prompts
func PromptDistinctTypes(dossierID string) (map[string][]string, error) {
var prompts []*Prompt
if err := Query("SELECT * FROM prompts WHERE dossier_id = ? AND active = 1", []any{dossierID}, &prompts); err != nil {
return nil, err
}
// Extract distinct category/type pairs
seen := make(map[string]bool)
result := make(map[string][]string)
for _, p := range prompts {
key := p.Category + "|" + p.Type
if !seen[key] && p.Category != "" && p.Type != "" {
seen[key] = true
result[p.Category] = append(result[p.Category], p.Type)
}
}
return result, nil
}
// --- RBAC PERMISSIONS ---
type PermissionFilter struct {
DossierID string // whose data
GranteeID string // who has access
EntryID string // specific entry
Role string // filter by role name
}
// AccessWrite saves one or more access grants
func AccessGrantWrite(grants ...*Access) error {
if len(grants) == 0 {
return nil
}
for _, a := range grants {
if a.AccessID == "" {
a.AccessID = NewID()
}
if a.CreatedAt == 0 {
a.CreatedAt = time.Now().Unix()
}
}
if len(grants) == 1 {
return Save("access", grants[0])
}
return Save("access", grants)
}
// AccessGrantRemove removes access grants by ID
func AccessGrantRemove(ids ...string) error {
for _, id := range ids {
if err := Delete("access", "access_id", id); err != nil {
return err
}
}
return nil
}
// AccessGrantGet retrieves a single access grant by ID
func AccessGrantGet(id string) (*Access, error) {
a := &Access{}
return a, Load("access", id, a)
}
// AccessGrantList retrieves access grants with optional filtering
func AccessGrantList(f *PermissionFilter) ([]*Access, error) {
q := "SELECT * FROM access WHERE 1=1"
args := []any{}
if f != nil {
if f.DossierID != "" {
q += " AND dossier_id = ?"
args = append(args, f.DossierID)
}
if f.GranteeID != "" {
q += " AND grantee_id = ?"
args = append(args, f.GranteeID)
}
if f.EntryID != "" {
q += " AND entry_id = ?"
args = append(args, f.EntryID)
}
if f.Role != "" {
q += " AND role = ?"
args = append(args, CryptoEncrypt(f.Role))
}
}
q += " ORDER BY created_at DESC"
var result []*Access
err := Query(q, args, &result)
return result, err
}
// AccessGrantsForGrantee returns all grants for a specific grantee, grouped by dossier
func AccessGrantsForGrantee(granteeID string) (map[string][]*Access, error) {
grants, err := AccessGrantList(&PermissionFilter{GranteeID: granteeID})
if err != nil {
return nil, err
}
result := make(map[string][]*Access)
for _, g := range grants {
result[g.DossierID] = append(result[g.DossierID], g)
}
return result, nil
}
// AccessRoleTemplates returns role templates (grantee_id is null) for a dossier
// Pass empty dossierID for system-wide templates
func AccessRoleTemplates(dossierID string) ([]*Access, error) {
q := "SELECT * FROM access WHERE grantee_id IS NULL OR grantee_id = ''"
args := []any{}
if dossierID != "" {
q += " AND (dossier_id = ? OR dossier_id IS NULL OR dossier_id = '')"
args = append(args, dossierID)
} else {
q += " AND (dossier_id IS NULL OR dossier_id = '')"
}
q += " ORDER BY role, entry_id"
var result []*Access
err := Query(q, args, &result)
return result, err
}
// AccessGrantRole copies role template entries for a specific grantee
func AccessGrantRole(dossierID, granteeID, role string) error {
// Get template entries for this role
templates, err := AccessRoleTemplates(dossierID)
if err != nil {
return err
}
var grants []*Access
for _, t := range templates {
if t.Role != role {
continue
}
grants = append(grants, &Access{
DossierID: dossierID,
GranteeID: granteeID,
EntryID: t.EntryID,
Role: role,
Ops: t.Ops,
})
}
if len(grants) == 0 {
// No template found, create default read-only grant
grants = append(grants, &Access{
DossierID: dossierID,
GranteeID: granteeID,
Role: role,
Ops: "r",
})
}
return AccessGrantWrite(grants...)
}
// AccessRevokeAll removes all access grants for a grantee to a specific dossier
func AccessRevokeAll(dossierID, granteeID string) error {
// Query for matching grants, then delete each by PK
grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID})
if err != nil {
return err
}
for _, g := range grants {
if err := Delete("access", "access_id", g.AccessID); err != nil {
return err
}
}
return nil
}
// AccessRevokeEntry removes a specific entry grant for a grantee
func AccessRevokeEntry(dossierID, granteeID, entryID string) error {
grants, err := AccessGrantList(&PermissionFilter{DossierID: dossierID, GranteeID: granteeID, EntryID: entryID})
if err != nil {
return err
}
for _, g := range grants {
if err := Delete("access", "access_id", g.AccessID); err != nil {
return err
}
}
return nil
}
// --- GENOME QUERIES ---
// Note: These functions use nil ctx for internal operations. When called from
// API handlers, the API should perform its own RBAC check first.
// GenomeGetExtraction returns the extraction entry for genome data
func GenomeGetExtraction(ctx *AccessContext, dossierID string) (*Entry, error) {
entries, err := EntryList(ctx, "", CategoryGenome, &EntryFilter{
DossierID: dossierID,
Type: "extraction",
Limit: 1,
})
if err != nil || len(entries) == 0 {
return nil, fmt.Errorf("no genome data")
}
return entries[0], nil
}
// GenomeTier represents a genome category tier
type GenomeTier struct {
TierID string
Category string
}
// GenomeGetTiers returns all tier entries for a genome extraction
func GenomeGetTiers(ctx *AccessContext, dossierID, extractionID string) ([]GenomeTier, error) {
entries, err := EntryList(ctx, extractionID, CategoryGenome, &EntryFilter{
DossierID: dossierID,
Type: "tier",
})
if err != nil {
return nil, err
}
var tiers []GenomeTier
for _, e := range entries {
tiers = append(tiers, GenomeTier{
TierID: e.EntryID,
Category: e.Value, // Value holds category name
})
}
return tiers, nil
}
// GenomeGetTierByCategory returns a specific tier by category name
func GenomeGetTierByCategory(ctx *AccessContext, dossierID, extractionID, category string) (*GenomeTier, error) {
entries, err := EntryList(ctx, extractionID, CategoryGenome, &EntryFilter{
DossierID: dossierID,
Type: "tier",
Value: category,
Limit: 1,
})
if err != nil || len(entries) == 0 {
return nil, fmt.Errorf("tier not found")
}
return &GenomeTier{
TierID: entries[0].EntryID,
Category: entries[0].Value,
}, nil
}
// GenomeVariant represents a genome variant with its metadata
type GenomeVariant struct {
EntryID string
RSID string
Genotype string
Gene string
Magnitude float64
Repute string
Summary string
Subcategory string
TierID string
}
// GenomeGetVariants returns variants for specified tier IDs
func GenomeGetVariants(ctx *AccessContext, dossierID string, tierIDs []string) ([]GenomeVariant, error) {
if len(tierIDs) == 0 {
return nil, nil
}
// Query entries for each tier and deduplicate by type (rsid)
seen := make(map[string]bool)
var variants []GenomeVariant
for _, tierID := range tierIDs {
entries, err := EntryList(ctx, tierID, CategoryGenome, &EntryFilter{DossierID: dossierID})
if err != nil {
continue
}
for _, e := range entries {
// Deduplicate by type (rsid)
if seen[e.Type] {
continue
}
seen[e.Type] = true
var data struct {
Mag float64 `json:"mag"`
Rep string `json:"rep"`
Sum string `json:"sum"`
Sub string `json:"sub"`
}
json.Unmarshal([]byte(e.Data), &data)
variants = append(variants, GenomeVariant{
EntryID: e.EntryID,
RSID: e.Type,
Genotype: e.Value,
Gene: e.Tags,
Magnitude: data.Mag,
Repute: data.Rep,
Summary: data.Sum,
Subcategory: data.Sub,
TierID: tierID,
})
}
}
return variants, nil
}
// GenomeGetVariantsByTier returns variants for a specific tier
func GenomeGetVariantsByTier(ctx *AccessContext, dossierID, tierID string) ([]GenomeVariant, error) {
entries, err := EntryList(ctx, tierID, CategoryGenome, &EntryFilter{DossierID: dossierID})
if err != nil {
return nil, err
}
var variants []GenomeVariant
for _, e := range entries {
var data struct {
Mag float64 `json:"mag"`
Rep string `json:"rep"`
Sum string `json:"sum"`
Sub string `json:"sub"`
}
json.Unmarshal([]byte(e.Data), &data)
variants = append(variants, GenomeVariant{
EntryID: e.EntryID,
RSID: e.Type,
Genotype: e.Value,
Gene: e.Tags,
Magnitude: data.Mag,
Repute: data.Rep,
Summary: data.Sum,
Subcategory: data.Sub,
TierID: tierID,
})
}
return variants, nil
}
// --- HELPERS ---
func deleteByIDs(table, col string, ids []string) error {
for _, id := range ids {
if err := Delete(table, col, id); err != nil {
return err
}
}
return nil
}