# messages

id: 8bf7833f-6e41-4248-9330-3a0a0dda1413
session_id: 69d4f621-0c76-43a4-b49f-a02ef9d97c02
role: assistant
model: local.accounts/fireworks/models/kimi-k2p5
parts:
  [{"type":"tool_call","data":{"id":"functions.agent:1","name":"agent","input":"{\"prompt\": \"Search the codebase to understand the current state of the Request-Centric Workflow implementation. I need to know:\\n\\n1. What database tables exist for requests, request lists, and request status tracking\\n2. What API endpoints exist for managing requests and their workflow\\n3. What UI templates exist for the request list view, request detail view, and status displays\\n4. What the current request status model looks like (Open, Assigned, Answered, Review, Published, etc.)\\n\\nFocus on files in lib/dbcore.go, api/handlers.go, api/routes.go, and portal/templates/app/*. Focus on understanding what's already implemented vs what might be missing for a complete request-centric workflow.\"}","type":"function","finished":true}},{"type":"finish","data":{"reason":"tool_use","time":1774088509}}]


id: 1346e6eb-80e8-4ba6-89b8-651a95eb60a6
session_id: functions.agent:1
role: user
parts:
  [{"type":"text","data":{"text":"Search the codebase to understand the current state of the Request-Centric Workflow implementation. I need to know:\n\n1. What database tables exist for requests, request lists, and request status tracking\n2. What API endpoints exist for managing requests and their workflow\n3. What UI templates exist for the request list view, request detail view, and status displays\n4. What the current request status model looks like (Open, Assigned, Answered, Review, Published, etc.)\n\nFocus on files in lib/dbcore.go, api/handlers.go, api/routes.go, and portal/templates/app/*. Focus on understanding what's already implemented vs what might be missing for a complete request-centric workflow."}},{"type":"finish","data":{"reason":"stop","time":0}}]


id: d8b3909f-8f8d-4055-ba56-3ee85da37756
session_id: functions.agent:1
role: assistant
model: local.accounts/fireworks/models/kimi-k2p5
parts:
  [{"type":"tool_call","data":{"id":"functions.view:0","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/lib/dbcore.go\"}","type":"function","finished":true}},{"type":"tool_call","data":{"id":"functions.view:1","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/lib/types.go\"}","type":"function","finished":true}},{"type":"tool_call","data":{"id":"functions.view:2","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/api/routes.go\"}","type":"function","finished":true}},{"type":"tool_call","data":{"id":"functions.view:3","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/api/handlers.go\"}","type":"function","finished":true}},{"type":"finish","data":{"reason":"tool_use","time":1774088511}}]


id: 90d09d4a-f03f-4c7d-bf65-cb786194a35c
session_id: functions.agent:1
role: tool
parts:
  [{"type":"tool_result","data":{"tool_call_id":"functions.view:0","name":"","content":"\u003cfile\u003e\n     1|package lib\n     2|\n     3|import (\n     4|\t\"crypto/subtle\"\n     5|\t\"database/sql\"\n     6|\t\"encoding/json\"\n     7|\t\"errors\"\n     8|\t\"fmt\"\n     9|\t\"os\"\n    10|\t\"path/filepath\"\n    11|\t\"sort\"\n    12|\t\"strings\"\n    13|\t\"time\"\n    14|\n    15|\t\"github.com/google/uuid\"\n    16|\t_ \"github.com/mattn/go-sqlite3\"\n    17|)\n    18|\n    19|var (\n    20|\tErrNotFound         = errors.New(\"not found\")\n    21|\tErrVersionConflict  = errors.New(\"version conflict: entry was modified by another request\")\n    22|\tErrSoftDeleted      = errors.New(\"entry has been deleted\")\n    23|)\n    24|\n    25|// OpenDB opens (or creates) the SQLite database and runs migrations.\n    26|func OpenDB(dbPath string, migrationPath string) (*DB, error) {\n    27|\tconn, err := sql.Open(\"sqlite3\", dbPath+\"?_journal_mode=WAL\u0026_foreign_keys=ON\u0026_busy_timeout=5000\")\n    28|\tif err != nil {\n    29|\t\treturn nil, fmt.Errorf(\"open db: %w\", err)\n    30|\t}\n    31|\n    32|\tif err := conn.Ping(); err != nil {\n    33|\t\treturn nil, fmt.Errorf(\"ping db: %w\", err)\n    34|\t}\n    35|\n    36|\tdb := \u0026DB{Conn: conn}\n    37|\n    38|\tif migrationPath != \"\" {\n    39|\t\tif err := db.runMigration(migrationPath); err != nil {\n    40|\t\t\treturn nil, fmt.Errorf(\"migration: %w\", err)\n    41|\t\t}\n    42|\t}\n    43|\n    44|\treturn db, nil\n    45|}\n    46|\n    47|func (db *DB) runMigration(path string) error {\n    48|\tinfo, err := os.Stat(path)\n    49|\tif err != nil {\n    50|\t\treturn err\n    51|\t}\n    52|\n    53|\t// If path is a directory, run all *.sql files sorted\n    54|\tif info.IsDir() {\n    55|\t\tentries, err := os.ReadDir(path)\n    56|\t\tif err != nil {\n    57|\t\t\treturn err\n    58|\t\t}\n    59|\t\tvar files []string\n    60|\t\tfor _, e := range entries {\n    61|\t\t\tif !e.IsDir() \u0026\u0026 strings.HasSuffix(e.Name(), \".sql\") {\n    62|\t\t\t\tfiles = append(files, e.Name())\n    63|\t\t\t}\n    64|\t\t}\n    65|\t\tsort.Strings(files)\n    66|\t\tfor _, f := range files {\n    67|\t\t\tif err := db.runSingleMigration(filepath.Join(path, f)); err != nil {\n    68|\t\t\t\treturn fmt.Errorf(\"%s: %w\", f, err)\n    69|\t\t\t}\n    70|\t\t}\n    71|\t\treturn nil\n    72|\t}\n    73|\n    74|\treturn db.runSingleMigration(path)\n    75|}\n    76|\n    77|func (db *DB) runSingleMigration(path string) error {\n    78|\tsqlBytes, err := os.ReadFile(path)\n    79|\tif err != nil {\n    80|\t\treturn err\n    81|\t}\n    82|\t_, err = db.Conn.Exec(string(sqlBytes))\n    83|\tif err != nil {\n    84|\t\t// Gracefully handle \"duplicate column\" for ALTER TABLE migrations\n    85|\t\tif strings.Contains(err.Error(), \"duplicate column\") {\n    86|\t\t\treturn nil\n    87|\t\t}\n    88|\t\treturn err\n    89|\t}\n    90|\treturn nil\n    91|}\n    92|\n    93|// Close closes the database connection.\n    94|func (db *DB) Close() error {\n    95|\treturn db.Conn.Close()\n    96|}\n    97|\n    98|// ---------------------------------------------------------------------------\n    99|// THE THREE CHOKE POINTS — all entry access goes through these\n   100|// ---------------------------------------------------------------------------\n   101|\n   102|// EntryRead queries entries with RBAC enforcement.\n   103|// Buyers cannot see pre_dataroom entries.\n   104|func EntryRead(db *DB, cfg *Config, actorID, projectID string, filter EntryFilter) ([]Entry, error) {\n   105|\t// Check read access\n   106|\trole, err := GetUserHighestRole(db, actorID, projectID)\n   107|\tif err != nil {\n   108|\t\treturn nil, err\n   109|\t}\n   110|\n   111|\tentries, err := entryQuery(db, filter)\n   112|\tif err != nil {\n   113|\t\treturn nil, err\n   114|\t}\n   115|\n   116|\t// Filter: buyers cannot see pre_dataroom entries\n   117|\tif IsBuyerRole(role) {\n   118|\t\tfiltered := entries[:0]\n   119|\t\tfor _, e := range entries {\n   120|\t\t\tif e.Stage != StagePreDataroom {\n   121|\t\t\t\tfiltered = append(filtered, e)\n   122|\t\t\t}\n   123|\t\t}\n   124|\t\tentries = filtered\n   125|\t}\n   126|\n   127|\t// Unpack encrypted fields\n   128|\tfor i := range entries {\n   129|\t\tif err := unpackEntry(cfg, \u0026entries[i]); err != nil {\n   130|\t\t\treturn nil, err\n   131|\t\t}\n   132|\t}\n   133|\n   134|\treturn entries, nil\n   135|}\n   136|\n   137|// EntryWrite creates or updates entries with RBAC enforcement and optimistic locking.\n   138|func EntryWrite(db *DB, cfg *Config, actorID string, entries ...*Entry) error {\n   139|\tnow := time.Now().UnixMilli()\n   140|\n   141|\tfor _, entry := range entries {\n   142|\t\t// Check write access\n   143|\t\tif err := CheckAccessWrite(db, actorID, entry.ProjectID, \"\"); err != nil {\n   144|\t\t\treturn err\n   145|\t\t}\n   146|\n   147|\t\t// Pack encrypted fields\n   148|\t\tif err := packEntry(cfg, entry); err != nil {\n   149|\t\t\treturn err\n   150|\t\t}\n   151|\n   152|\t\tif entry.EntryID == \"\" {\n   153|\t\t\t// New entry\n   154|\t\t\tentry.EntryID = uuid.New().String()\n   155|\t\t\tentry.CreatedBy = actorID\n   156|\t\t\tentry.CreatedAt = now\n   157|\t\t\tentry.UpdatedAt = now\n   158|\t\t\tentry.Version = 1\n   159|\t\t\tentry.KeyVersion = 1\n   160|\n   161|\t\t\tif err := entryInsert(db, entry); err != nil {\n   162|\t\t\t\treturn err\n   163|\t\t\t}\n   164|\t\t} else {\n   165|\t\t\t// Update with optimistic locking\n   166|\t\t\tentry.UpdatedAt = now\n   167|\t\t\tif err := entryUpdate(db, entry); err != nil {\n   168|\t\t\t\treturn err\n   169|\t\t\t}\n   170|\t\t}\n   171|\t}\n   172|\treturn nil\n   173|}\n   174|\n   175|// EntryDelete soft-deletes entries with RBAC enforcement.\n   176|func EntryDelete(db *DB, actorID, projectID string, entryIDs ...string) error {\n   177|\tif err := CheckAccessDelete(db, actorID, projectID, \"\"); err != nil {\n   178|\t\treturn err\n   179|\t}\n   180|\n   181|\tnow := time.Now().UnixMilli()\n   182|\tfor _, id := range entryIDs {\n   183|\t\tif err := entrySoftDelete(db, id, actorID, now); err != nil {\n   184|\t\t\treturn err\n   185|\t\t}\n   186|\t}\n   187|\treturn nil\n   188|}\n   189|\n   190|// entryReadSystem is the unexported system bypass — NO RBAC checks.\n   191|// Used internally for tree walks (e.g., ResolveWorkstreamID).\n   192|func entryReadSystem(db *DB, entryID string) (*Entry, error) {\n   193|\trow := db.Conn.QueryRow(\n   194|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2,\n   195|\t\t        summary, data, stage, assignee_id, return_to_id, origin_id,\n   196|\t\t        version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by\n   197|\t\t FROM entries WHERE entry_id = ?`, entryID,\n   198|\t)\n   199|\treturn scanEntry(row)\n   200|}\n   201|\n   202|// ---------------------------------------------------------------------------\n   203|// Internal SQL — never exported, never called outside this file\n   204|// ---------------------------------------------------------------------------\n   205|\n   206|func entryQuery(db *DB, f EntryFilter) ([]Entry, error) {\n   207|\tq := `SELECT entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2,\n   208|\t             summary, data, stage, assignee_id, return_to_id, origin_id,\n   209|\t             version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by\n   210|\t      FROM entries WHERE deleted_at IS NULL`\n   211|\tvar args []any\n   212|\n   213|\tif f.ProjectID != \"\" {\n   214|\t\tq += \" AND project_id = ?\"\n   215|\t\targs = append(args, f.ProjectID)\n   216|\t}\n   217|\tif f.ParentID != nil {\n   218|\t\tq += \" AND parent_id = ?\"\n   219|\t\targs = append(args, *f.ParentID)\n   220|\t}\n   221|\tif f.Type != \"\" {\n   222|\t\tq += \" AND type = ?\"\n   223|\t\targs = append(args, f.Type)\n   224|\t}\n   225|\tif f.Stage != \"\" {\n   226|\t\tq += \" AND stage = ?\"\n   227|\t\targs = append(args, f.Stage)\n   228|\t}\n   229|\tif f.AssigneeID != \"\" {\n   230|\t\tq += \" AND assignee_id = ?\"\n   231|\t\targs = append(args, f.AssigneeID)\n   232|\t}\n   233|\tif f.SearchKey != nil {\n   234|\t\tq += \" AND search_key = ?\"\n   235|\t\targs = append(args, f.SearchKey)\n   236|\t}\n   237|\n   238|\tq += \" ORDER BY sort_order ASC, created_at ASC\"\n   239|\n   240|\tif f.Limit \u003e 0 {\n   241|\t\tq += \" LIMIT ?\"\n   242|\t\targs = append(args, f.Limit)\n   243|\t}\n   244|\tif f.Offset \u003e 0 {\n   245|\t\tq += \" OFFSET ?\"\n   246|\t\targs = append(args, f.Offset)\n   247|\t}\n   248|\n   249|\trows, err := db.Conn.Query(q, args...)\n   250|\tif err != nil {\n   251|\t\treturn nil, err\n   252|\t}\n   253|\tdefer rows.Close()\n   254|\n   255|\tvar entries []Entry\n   256|\tfor rows.Next() {\n   257|\t\te, err := scanEntryRow(rows)\n   258|\t\tif err != nil {\n   259|\t\t\treturn nil, err\n   260|\t\t}\n   261|\t\tentries = append(entries, *e)\n   262|\t}\n   263|\treturn entries, rows.Err()\n   264|}\n   265|\n   266|func entryInsert(db *DB, e *Entry) error {\n   267|\t_, err := db.Conn.Exec(\n   268|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n   269|\t\t  search_key, search_key2, summary, data, stage,\n   270|\t\t  assignee_id, return_to_id, origin_id,\n   271|\t\t  version, deleted_at, deleted_by, key_version,\n   272|\t\t  created_at, updated_at, created_by)\n   273|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n   274|\t\te.EntryID, e.ProjectID, e.ParentID, e.Type, e.Depth, e.SortOrder,\n   275|\t\te.SearchKey, e.SearchKey2, e.Summary, e.Data, e.Stage,\n   276|\t\te.AssigneeID, e.ReturnToID, e.OriginID,\n   277|\t\te.Version, e.DeletedAt, e.DeletedBy, e.KeyVersion,\n   278|\t\te.CreatedAt, e.UpdatedAt, e.CreatedBy,\n   279|\t)\n   280|\treturn err\n   281|}\n   282|\n   283|func entryUpdate(db *DB, e *Entry) error {\n   284|\tresult, err := db.Conn.Exec(\n   285|\t\t`UPDATE entries SET\n   286|\t\t  parent_id=?, type=?, depth=?, sort_order=?, search_key=?, search_key2=?,\n   287|\t\t  summary=?, data=?, stage=?,\n   288|\t\t  assignee_id=?, return_to_id=?, origin_id=?,\n   289|\t\t  version=version+1, updated_at=?\n   290|\t\t WHERE entry_id = ? AND version = ? AND deleted_at IS NULL`,\n   291|\t\te.ParentID, e.Type, e.Depth, e.SortOrder, e.SearchKey, e.SearchKey2,\n   292|\t\te.Summary, e.Data, e.Stage,\n   293|\t\te.AssigneeID, e.ReturnToID, e.OriginID,\n   294|\t\te.UpdatedAt,\n   295|\t\te.EntryID, e.Version,\n   296|\t)\n   297|\tif err != nil {\n   298|\t\treturn err\n   299|\t}\n   300|\taffected, err := result.RowsAffected()\n   301|\tif err != nil {\n   302|\t\treturn err\n   303|\t}\n   304|\tif affected == 0 {\n   305|\t\treturn ErrVersionConflict\n   306|\t}\n   307|\te.Version++\n   308|\treturn nil\n   309|}\n   310|\n   311|func entrySoftDelete(db *DB, entryID, actorID string, now int64) error {\n   312|\tresult, err := db.Conn.Exec(\n   313|\t\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ?\n   314|\t\t WHERE entry_id = ? AND deleted_at IS NULL`,\n   315|\t\tnow, actorID, now, entryID,\n   316|\t)\n   317|\tif err != nil {\n   318|\t\treturn err\n   319|\t}\n   320|\taffected, err := result.RowsAffected()\n   321|\tif err != nil {\n   322|\t\treturn err\n   323|\t}\n   324|\tif affected == 0 {\n   325|\t\treturn ErrNotFound\n   326|\t}\n   327|\treturn nil\n   328|}\n   329|\n   330|// scanEntry scans a single row into an Entry.\n   331|func scanEntry(row *sql.Row) (*Entry, error) {\n   332|\tvar e Entry\n   333|\terr := row.Scan(\n   334|\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n   335|\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n   336|\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n   337|\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n   338|\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n   339|\t)\n   340|\tif errors.Is(err, sql.ErrNoRows) {\n   341|\t\treturn nil, nil\n   342|\t}\n   343|\tif err != nil {\n   344|\t\treturn nil, err\n   345|\t}\n   346|\treturn \u0026e, nil\n   347|}\n   348|\n   349|// scanEntryRow scans a row from sql.Rows into an Entry.\n   350|func scanEntryRow(rows *sql.Rows) (*Entry, error) {\n   351|\tvar e Entry\n   352|\terr := rows.Scan(\n   353|\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n   354|\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n   355|\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n   356|\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n   357|\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n   358|\t)\n   359|\tif err != nil {\n   360|\t\treturn nil, err\n   361|\t}\n   362|\treturn \u0026e, nil\n   363|}\n   364|\n   365|// packEntry encrypts the text fields of an entry before storage.\n   366|func packEntry(cfg *Config, e *Entry) error {\n   367|\tkey, err := DeriveProjectKey(cfg.MasterKey, e.ProjectID)\n   368|\tif err != nil {\n   369|\t\treturn err\n   370|\t}\n   371|\n   372|\tif e.SummaryText != \"\" {\n   373|\t\tpacked, err := Pack(key, e.SummaryText)\n   374|\t\tif err != nil {\n   375|\t\t\treturn err\n   376|\t\t}\n   377|\t\te.Summary = packed\n   378|\t}\n   379|\n   380|\tif e.DataText != \"\" {\n   381|\t\tpacked, err := Pack(key, e.DataText)\n   382|\t\tif err != nil {\n   383|\t\t\treturn err\n   384|\t\t}\n   385|\t\te.Data = packed\n   386|\t}\n   387|\n   388|\treturn nil\n   389|}\n   390|\n   391|// unpackEntry decrypts the packed fields of an entry after read.\n   392|func unpackEntry(cfg *Config, e *Entry) error {\n   393|\tkey, err := DeriveProjectKey(cfg.MasterKey, e.ProjectID)\n   394|\tif err != nil {\n   395|\t\treturn err\n   396|\t}\n   397|\n   398|\tif len(e.Summary) \u003e 0 {\n   399|\t\ttext, err := Unpack(key, e.Summary)\n   400|\t\tif err != nil {\n   401|\t\t\treturn err\n   402|\t\t}\n   403|\t\te.SummaryText = text\n   404|\t}\n   405|\n   406|\tif len(e.Data) \u003e 0 {\n   407|\t\ttext, err := Unpack(key, e.Data)\n   408|\t\tif err != nil {\n   409|\t\t\treturn err\n   410|\t\t}\n   411|\t\te.DataText = text\n   412|\t}\n   413|\n   414|\treturn nil\n   415|}\n   416|\n   417|// unpackEntryWithKey decrypts an entry using a pre-derived key (used for templates).\n   418|func unpackEntryWithKey(key []byte, e *Entry) error {\n   419|\tif len(e.Summary) \u003e 0 {\n   420|\t\ttext, err := Unpack(key, e.Summary)\n   421|\t\tif err != nil {\n   422|\t\t\treturn err\n   423|\t\t}\n   424|\t\te.SummaryText = text\n   425|\t}\n   426|\tif len(e.Data) \u003e 0 {\n   427|\t\ttext, err := Unpack(key, e.Data)\n   428|\t\tif err != nil {\n   429|\t\t\treturn err\n   430|\t\t}\n   431|\t\te.DataText = text\n   432|\t}\n   433|\treturn nil\n   434|}\n   435|\n   436|// ---------------------------------------------------------------------------\n   437|// User operations\n   438|// ---------------------------------------------------------------------------\n   439|\n   440|// UserCreate inserts a new user.\n   441|func UserCreate(db *DB, u *User) error {\n   442|\t_, err := db.Conn.Exec(\n   443|\t\t`INSERT INTO users (user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at)\n   444|\t\t VALUES (?,?,?,?,?,?,?,?,?,?)`,\n   445|\t\tu.UserID, u.Email, u.Name, u.Password, u.OrgID, u.OrgName, u.MFASecret, u.Active, u.CreatedAt, u.UpdatedAt,\n   446|\t)\n   447|\treturn err\n   448|}\n   449|\n   450|// UserByEmail looks up a user by email.\n   451|func UserByEmail(db *DB, email string) (*User, error) {\n   452|\tvar u User\n   453|\tvar active int\n   454|\terr := db.Conn.QueryRow(\n   455|\t\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\n   456|\t\t FROM users WHERE email = ?`, email,\n   457|\t).Scan(\u0026u.UserID, \u0026u.Email, \u0026u.Name, \u0026u.Password, \u0026u.OrgID, \u0026u.OrgName, \u0026u.MFASecret, \u0026active, \u0026u.CreatedAt, \u0026u.UpdatedAt)\n   458|\tif errors.Is(err, sql.ErrNoRows) {\n   459|\t\treturn nil, nil\n   460|\t}\n   461|\tif err != nil {\n   462|\t\treturn nil, err\n   463|\t}\n   464|\tu.Active = active == 1\n   465|\treturn \u0026u, nil\n   466|}\n   467|\n   468|// UserByID looks up a user by ID.\n   469|func UserByID(db *DB, userID string) (*User, error) {\n   470|\tvar u User\n   471|\tvar active int\n   472|\terr := db.Conn.QueryRow(\n   473|\t\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\n   474|\t\t FROM users WHERE user_id = ?`, userID,\n   475|\t).Scan(\u0026u.UserID, \u0026u.Email, \u0026u.Name, \u0026u.Password, \u0026u.OrgID, \u0026u.OrgName, \u0026u.MFASecret, \u0026active, \u0026u.CreatedAt, \u0026u.UpdatedAt)\n   476|\tif errors.Is(err, sql.ErrNoRows) {\n   477|\t\treturn nil, nil\n   478|\t}\n   479|\tif err != nil {\n   480|\t\treturn nil, err\n   481|\t}\n   482|\tu.Active = active == 1\n   483|\treturn \u0026u, nil\n   484|}\n   485|\n   486|// UserCount returns the number of users in the database.\n   487|func UserCount(db *DB) (int, error) {\n   488|\tvar count int\n   489|\terr := db.Conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(\u0026count)\n   490|\treturn count, err\n   491|}\n   492|\n   493|// ProjectsByUser returns all projects a user has access to.\n   494|func ProjectsByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {\n   495|\trows, err := db.Conn.Query(\n   496|\t\t`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth, e.sort_order,\n   497|\t\t        e.search_key, e.search_key2, e.summary, e.data, e.stage,\n   498|\t\t        e.assignee_id, e.return_to_id, e.origin_id,\n   499|\t\t        e.version, e.deleted_at, e.deleted_by, e.key_version,\n   500|\t\t        e.created_at, e.updated_at, e.created_by\n   501|\t\t FROM entries e\n   502|\t\t JOIN access a ON a.project_id = e.project_id\n   503|\t\t WHERE a.user_id = ? AND a.revoked_at IS NULL AND e.type = 'project' AND e.deleted_at IS NULL\n   504|\t\t ORDER BY e.updated_at DESC`, userID,\n   505|\t)\n   506|\tif err != nil {\n   507|\t\treturn nil, err\n   508|\t}\n   509|\tdefer rows.Close()\n   510|\n   511|\tvar entries []Entry\n   512|\tfor rows.Next() {\n   513|\t\te, err := scanEntryRow(rows)\n   514|\t\tif err != nil {\n   515|\t\t\treturn nil, err\n   516|\t\t}\n   517|\t\tif err := unpackEntry(cfg, e); err != nil {\n   518|\t\t\treturn nil, err\n   519|\t\t}\n   520|\t\tentries = append(entries, *e)\n   521|\t}\n   522|\treturn entries, rows.Err()\n   523|}\n   524|\n   525|// TasksByUser returns all entries assigned to a user across all projects.\n   526|func TasksByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {\n   527|\trows, err := db.Conn.Query(\n   528|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n   529|\t\t        search_key, search_key2, summary, data, stage,\n   530|\t\t        assignee_id, return_to_id, origin_id,\n   531|\t\t        version, deleted_at, deleted_by, key_version,\n   532|\t\t        created_at, updated_at, created_by\n   533|\t\t FROM entries\n   534|\t\t WHERE assignee_id = ? AND deleted_at IS NULL\n   535|\t\t ORDER BY created_at DESC`, userID,\n   536|\t)\n   537|\tif err != nil {\n   538|\t\treturn nil, err\n   539|\t}\n   540|\tdefer rows.Close()\n   541|\n   542|\tvar entries []Entry\n   543|\tfor rows.Next() {\n   544|\t\te, err := scanEntryRow(rows)\n   545|\t\tif err != nil {\n   546|\t\t\treturn nil, err\n   547|\t\t}\n   548|\t\tif err := unpackEntry(cfg, e); err != nil {\n   549|\t\t\treturn nil, err\n   550|\t\t}\n   551|\t\tentries = append(entries, *e)\n   552|\t}\n   553|\treturn entries, rows.Err()\n   554|}\n   555|\n   556|// EntriesByParent returns entries with a given parent ID.\n   557|func EntriesByParent(db *DB, cfg *Config, parentID string) ([]Entry, error) {\n   558|\trows, err := db.Conn.Query(\n   559|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n   560|\t\t        search_key, search_key2, summary, data, stage,\n   561|\t\t        assignee_id, return_to_id, origin_id,\n   562|\t\t        version, deleted_at, deleted_by, key_version,\n   563|\t\t        created_at, updated_at, created_by\n   564|\t\t FROM entries\n   565|\t\t WHERE parent_id = ? AND deleted_at IS NULL\n   566|\t\t ORDER BY sort_order ASC, created_at ASC`, parentID,\n   567|\t)\n   568|\tif err != nil {\n   569|\t\treturn nil, err\n   570|\t}\n   571|\tdefer rows.Close()\n   572|\n   573|\tvar entries []Entry\n   574|\tfor rows.Next() {\n   575|\t\te, err := scanEntryRow(rows)\n   576|\t\tif err != nil {\n   577|\t\t\treturn nil, err\n   578|\t\t}\n   579|\t\tif err := unpackEntry(cfg, e); err != nil {\n   580|\t\t\treturn nil, err\n   581|\t\t}\n   582|\t\tentries = append(entries, *e)\n   583|\t}\n   584|\treturn entries, rows.Err()\n   585|}\n   586|\n   587|// EntryByID returns a single entry by ID (with RBAC bypass for internal use).\n   588|func EntryByID(db *DB, cfg *Config, entryID string) (*Entry, error) {\n   589|\te, err := entryReadSystem(db, entryID)\n   590|\tif err != nil {\n   591|\t\treturn nil, err\n   592|\t}\n   593|\tif e == nil {\n   594|\t\treturn nil, nil\n   595|\t}\n   596|\tif err := unpackEntry(cfg, e); err != nil {\n   597|\t\treturn nil, err\n   598|\t}\n   599|\treturn e, nil\n   600|}\n   601|\n   602|// RequestCountByProject returns the number of requests in a project.\n   603|func RequestCountByProject(db *DB, projectID string) (int, int, error) {\n   604|\tvar total, open int\n   605|\terr := db.Conn.QueryRow(\n   606|\t\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL`,\n   607|\t\tprojectID,\n   608|\t).Scan(\u0026total)\n   609|\tif err != nil {\n   610|\t\treturn 0, 0, err\n   611|\t}\n   612|\terr = db.Conn.QueryRow(\n   613|\t\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL AND stage = 'pre_dataroom'`,\n   614|\t\tprojectID,\n   615|\t).Scan(\u0026open)\n   616|\treturn total, open, err\n   617|}\n   618|\n   619|// WorkstreamCountByProject returns the number of workstreams in a project.\n   620|func WorkstreamCountByProject(db *DB, projectID string) (int, error) {\n   621|\tvar count int\n   622|\terr := db.Conn.QueryRow(\n   623|\t\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'workstream' AND deleted_at IS NULL`,\n   624|\t\tprojectID,\n   625|\t).Scan(\u0026count)\n   626|\treturn count, err\n   627|}\n   628|\n   629|// ---------------------------------------------------------------------------\n   630|// Challenge operations (passwordless OTP auth)\n   631|// ---------------------------------------------------------------------------\n   632|\n   633|// ChallengeCreate inserts a new email challenge.\n   634|func ChallengeCreate(db *DB, c *Challenge) error {\n   635|\t_, err := db.Conn.Exec(\n   636|\t\t`INSERT INTO challenges (challenge_id, email, code, created_at, expires_at, used)\n   637|\t\t VALUES (?,?,?,?,?,?)`,\n   638|\t\tc.ChallengeID, c.Email, c.Code, c.CreatedAt, c.ExpiresAt, c.Used,\n   639|\t)\n   640|\treturn err\n   641|}\n   642|\n   643|// ChallengeVerify looks up the most recent unused challenge for an email and marks it used if the code matches.\n   644|// Returns the challenge if valid, nil if not found or invalid.\n   645|func ChallengeVerify(db *DB, email, code string) (*Challenge, error) {\n   646|\tvar c Challenge\n   647|\tvar used int\n   648|\terr := db.Conn.QueryRow(\n   649|\t\t`SELECT challenge_id, email, code, created_at, expires_at, used\n   650|\t\t FROM challenges\n   651|\t\t WHERE email = ? AND used = 0\n   652|\t\t ORDER BY created_at DESC LIMIT 1`, email,\n   653|\t).Scan(\u0026c.ChallengeID, \u0026c.Email, \u0026c.Code, \u0026c.CreatedAt, \u0026c.ExpiresAt, \u0026used)\n   654|\tif err != nil {\n   655|\t\treturn nil, nil // no challenge found\n   656|\t}\n   657|\tc.Used = used == 1\n   658|\n   659|\t// Check expiry\n   660|\tif c.ExpiresAt \u003c time.Now().UnixMilli() {\n   661|\t\treturn nil, nil\n   662|\t}\n   663|\n   664|\t// Check code - constant-time comparison to prevent timing attacks\n   665|\tif subtle.ConstantTimeCompare([]byte(c.Code), []byte(code)) != 1 {\n   666|\t\treturn nil, nil\n   667|\t}\n   668|\n   669|\t// Mark as used\n   670|\t_, err = db.Conn.Exec(`UPDATE challenges SET used = 1 WHERE challenge_id = ?`, c.ChallengeID)\n   671|\tif err != nil {\n   672|\t\treturn nil, err\n   673|\t}\n   674|\n   675|\treturn \u0026c, nil\n   676|}\n   677|\n   678|// ---------------------------------------------------------------------------\n   679|// Admin query helpers\n   680|// ---------------------------------------------------------------------------\n   681|\n   682|// AllUsers returns all users (for super admin).\n   683|func AllUsers(db *DB) ([]User, error) {\n   684|\trows, err := db.Conn.Query(\n   685|\t\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\n   686|\t\t FROM users ORDER BY created_at DESC`,\n   687|\t)\n   688|\tif err != nil {\n   689|\t\treturn nil, err\n   690|\t}\n   691|\tdefer rows.Close()\n   692|\n   693|\tvar users []User\n   694|\tfor rows.Next() {\n   695|\t\tvar u User\n   696|\t\tvar active int\n   697|\t\tif err := rows.Scan(\u0026u.UserID, \u0026u.Email, \u0026u.Name, \u0026u.Password, \u0026u.OrgID, \u0026u.OrgName, \u0026u.MFASecret, \u0026active, \u0026u.CreatedAt, \u0026u.UpdatedAt); err != nil {\n   698|\t\t\treturn nil, err\n   699|\t\t}\n   700|\t\tu.Active = active == 1\n   701|\t\tusers = append(users, u)\n   702|\t}\n   703|\treturn users, rows.Err()\n   704|}\n   705|\n   706|// AllProjects returns all projects (for super admin, no RBAC).\n   707|func AllProjects(db *DB, cfg *Config) ([]Entry, error) {\n   708|\trows, err := db.Conn.Query(\n   709|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n   710|\t\t        search_key, search_key2, summary, data, stage,\n   711|\t\t        assignee_id, return_to_id, origin_id,\n   712|\t\t        version, deleted_at, deleted_by, key_version,\n   713|\t\t        created_at, updated_at, created_by\n   714|\t\t FROM entries WHERE type = 'project' AND deleted_at IS NULL\n   715|\t\t ORDER BY updated_at DESC`,\n   716|\t)\n   717|\tif err != nil {\n   718|\t\treturn nil, err\n   719|\t}\n   720|\tdefer rows.Close()\n   721|\n   722|\tvar entries []Entry\n   723|\tfor rows.Next() {\n   724|\t\te, err := scanEntryRow(rows)\n   725|\t\tif err != nil {\n   726|\t\t\treturn nil, err\n   727|\t\t}\n   728|\t\tif err := unpackEntry(cfg, e); err != nil {\n   729|\t\t\treturn nil, err\n   730|\t\t}\n   731|\t\tentries = append(entries, *e)\n   732|\t}\n   733|\treturn entries, rows.Err()\n   734|}\n   735|\n   736|// AuditRecent returns the most recent audit entries (for super admin).\n   737|func AuditRecent(db *DB, limit int) ([]AuditEntry, error) {\n   738|\trows, err := db.Conn.Query(\n   739|\t\t`SELECT id, project_id, actor_id, action, target_id, details, ip, ts\n   740|\t\t FROM audit ORDER BY ts DESC LIMIT ?`, limit,\n   741|\t)\n   742|\tif err != nil {\n   743|\t\treturn nil, err\n   744|\t}\n   745|\tdefer rows.Close()\n   746|\n   747|\tvar entries []AuditEntry\n   748|\tfor rows.Next() {\n   749|\t\tvar a AuditEntry\n   750|\t\tvar targetID, ip *string\n   751|\t\tif err := rows.Scan(\u0026a.ID, \u0026a.ProjectID, \u0026a.ActorID, \u0026a.Action, \u0026targetID, \u0026a.Details, \u0026ip, \u0026a.Ts); err != nil {\n   752|\t\t\treturn nil, err\n   753|\t\t}\n   754|\t\tif targetID != nil {\n   755|\t\t\ta.TargetID = *targetID\n   756|\t\t}\n   757|\t\tif ip != nil {\n   758|\t\t\ta.IP = *ip\n   759|\t\t}\n   760|\t\tentries = append(entries, a)\n   761|\t}\n   762|\treturn entries, rows.Err()\n   763|}\n   764|\n   765|// ---------------------------------------------------------------------------\n   766|// Access operations\n   767|// ---------------------------------------------------------------------------\n   768|\n   769|// AccessGrant creates a new access grant.\n   770|func AccessGrant(db *DB, a *Access) error {\n   771|\t_, err := db.Conn.Exec(\n   772|\t\t`INSERT INTO access (id, project_id, workstream_id, user_id, role, ops, can_grant, granted_by, granted_at)\n   773|\t\t VALUES (?,?,?,?,?,?,?,?,?)`,\n   774|\t\ta.ID, a.ProjectID, a.WorkstreamID, a.UserID, a.Role, a.Ops, a.CanGrant, a.GrantedBy, a.GrantedAt,\n   775|\t)\n   776|\treturn err\n   777|}\n   778|\n   779|// AccessRevoke soft-revokes an access grant.\n   780|func AccessRevoke(db *DB, accessID, revokedBy string) error {\n   781|\tnow := time.Now().UnixMilli()\n   782|\t_, err := db.Conn.Exec(\n   783|\t\t`UPDATE access SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL`,\n   784|\t\tnow, revokedBy, accessID,\n   785|\t)\n   786|\treturn err\n   787|}\n   788|\n   789|// ---------------------------------------------------------------------------\n   790|// Session operations\n   791|// ---------------------------------------------------------------------------\n   792|\n   793|// SessionCreate inserts a new session.\n   794|func SessionCreate(db *DB, s *Session) error {\n   795|\t_, err := db.Conn.Exec(\n   796|\t\t`INSERT INTO sessions (id, user_id, fingerprint, created_at, expires_at, revoked)\n   797|\t\t VALUES (?,?,?,?,?,?)`,\n   798|\t\ts.ID, s.UserID, s.Fingerprint, s.CreatedAt, s.ExpiresAt, s.Revoked,\n   799|\t)\n   800|\treturn err\n   801|}\n   802|\n   803|// SessionByID retrieves a session.\n   804|func SessionByID(db *DB, sessionID string) (*Session, error) {\n   805|\tvar s Session\n   806|\tvar revoked int\n   807|\terr := db.Conn.QueryRow(\n   808|\t\t`SELECT id, user_id, fingerprint, created_at, expires_at, revoked\n   809|\t\t FROM sessions WHERE id = ?`, sessionID,\n   810|\t).Scan(\u0026s.ID, \u0026s.UserID, \u0026s.Fingerprint, \u0026s.CreatedAt, \u0026s.ExpiresAt, \u0026revoked)\n   811|\tif errors.Is(err, sql.ErrNoRows) {\n   812|\t\treturn nil, nil\n   813|\t}\n   814|\tif err != nil {\n   815|\t\treturn nil, err\n   816|\t}\n   817|\ts.Revoked = revoked == 1\n   818|\treturn \u0026s, nil\n   819|}\n   820|\n   821|// SessionRevoke revokes a session.\n   822|func SessionRevoke(db *DB, sessionID string) error {\n   823|\t_, err := db.Conn.Exec(\n   824|\t\t`UPDATE sessions SET revoked = 1 WHERE id = ?`, sessionID,\n   825|\t)\n   826|\treturn err\n   827|}\n   828|\n   829|// SessionRevokeAllForUser revokes all sessions for a user.\n   830|func SessionRevokeAllForUser(db *DB, userID string) error {\n   831|\t_, err := db.Conn.Exec(\n   832|\t\t`UPDATE sessions SET revoked = 1 WHERE user_id = ? AND revoked = 0`, userID,\n   833|\t)\n   834|\treturn err\n   835|}\n   836|\n   837|// ---------------------------------------------------------------------------\n   838|// Audit operations\n   839|// ---------------------------------------------------------------------------\n   840|\n   841|// AuditLog records a security event.\n   842|func AuditLog(db *DB, cfg *Config, a *AuditEntry) error {\n   843|\tif a.ID == \"\" {\n   844|\t\ta.ID = uuid.New().String()\n   845|\t}\n   846|\tif a.Ts == 0 {\n   847|\t\ta.Ts = time.Now().UnixMilli()\n   848|\t}\n   849|\t_, err := db.Conn.Exec(\n   850|\t\t`INSERT INTO audit (id, project_id, actor_id, action, target_id, details, ip, ts)\n   851|\t\t VALUES (?,?,?,?,?,?,?,?)`,\n   852|\t\ta.ID, a.ProjectID, a.ActorID, a.Action, a.TargetID, a.Details, a.IP, a.Ts,\n   853|\t)\n   854|\treturn err\n   855|}\n   856|\n   857|// ---------------------------------------------------------------------------\n   858|// Entry event operations\n   859|// ---------------------------------------------------------------------------\n   860|\n   861|// EntryEventCreate records a workflow event on an entry.\n   862|func EntryEventCreate(db *DB, ev *EntryEvent) error {\n   863|\tif ev.ID == \"\" {\n   864|\t\tev.ID = uuid.New().String()\n   865|\t}\n   866|\tif ev.Ts == 0 {\n   867|\t\tev.Ts = time.Now().UnixMilli()\n   868|\t}\n   869|\t_, err := db.Conn.Exec(\n   870|\t\t`INSERT INTO entry_events (id, entry_id, actor_id, channel, action, data, ts)\n   871|\t\t VALUES (?,?,?,?,?,?,?)`,\n   872|\t\tev.ID, ev.EntryID, ev.ActorID, ev.Channel, ev.Action, ev.Data, ev.Ts,\n   873|\t)\n   874|\treturn err\n   875|}\n   876|\n   877|// ---------------------------------------------------------------------------\n   878|// Answer link operations\n   879|// ---------------------------------------------------------------------------\n   880|\n   881|// AnswerLinkCreate links an answer to a request.\n   882|func AnswerLinkCreate(db *DB, answerID, requestID, linkedBy string) error {\n   883|\tnow := time.Now().UnixMilli()\n   884|\t_, err := db.Conn.Exec(\n   885|\t\t`INSERT OR IGNORE INTO answer_links (answer_id, request_id, linked_by, linked_at, confirmed, status)\n   886|\t\t VALUES (?,?,?,?,0,'pending')`,\n   887|\t\tanswerID, requestID, linkedBy, now,\n   888|\t)\n   889|\treturn err\n   890|}\n   891|\n   892|// AnswerLinksByRequest returns all answer links for a request.\n   893|func AnswerLinksByRequest(db *DB, requestID string) ([]AnswerLink, error) {\n   894|\trows, err := db.Conn.Query(\n   895|\t\t`SELECT answer_id, request_id, linked_by, linked_at, confirmed, ai_score, status,\n   896|\t\t        reviewed_by, reviewed_at, reject_reason\n   897|\t\t FROM answer_links WHERE request_id = ?\n   898|\t\t ORDER BY linked_at ASC`, requestID,\n   899|\t)\n   900|\tif err != nil {\n   901|\t\treturn nil, err\n   902|\t}\n   903|\tdefer rows.Close()\n   904|\n   905|\tvar links []AnswerLink\n   906|\tfor rows.Next() {\n   907|\t\tvar l AnswerLink\n   908|\t\tif err := rows.Scan(\u0026l.AnswerID, \u0026l.RequestID, \u0026l.LinkedBy, \u0026l.LinkedAt,\n   909|\t\t\t\u0026l.Confirmed, \u0026l.AIScore, \u0026l.Status,\n   910|\t\t\t\u0026l.ReviewedBy, \u0026l.ReviewedAt, \u0026l.RejectReason); err != nil {\n   911|\t\t\treturn nil, err\n   912|\t\t}\n   913|\t\tlinks = append(links, l)\n   914|\t}\n   915|\treturn links, rows.Err()\n   916|}\n   917|\n   918|// AnswerLinkDelete removes a link between an answer and a request.\n   919|func AnswerLinkDelete(db *DB, answerID, requestID string) error {\n   920|\t_, err := db.Conn.Exec(\n   921|\t\t`DELETE FROM answer_links WHERE answer_id = ? AND request_id = ?`,\n   922|\t\tanswerID, requestID,\n   923|\t)\n   924|\treturn err\n   925|}\n   926|\n   927|// AnswerLinkCountsByProject returns a map of request_id → count of linked answers.\n   928|func AnswerLinkCountsByProject(db *DB, projectID string) (map[string]int, error) {\n   929|\trows, err := db.Conn.Query(\n   930|\t\t`SELECT al.request_id, COUNT(*)\n   931|\t\t FROM answer_links al\n   932|\t\t JOIN entries e ON e.entry_id = al.request_id\n   933|\t\t WHERE e.project_id = ? AND e.deleted_at IS NULL\n   934|\t\t GROUP BY al.request_id`, projectID,\n   935|\t)\n   936|\tif err != nil {\n   937|\t\treturn nil, err\n   938|\t}\n   939|\tdefer rows.Close()\n   940|\n   941|\tcounts := make(map[string]int)\n   942|\tfor rows.Next() {\n   943|\t\tvar reqID string\n   944|\t\tvar count int\n   945|\t\tif err := rows.Scan(\u0026reqID, \u0026count); err != nil {\n   946|\t\t\treturn nil, err\n   947|\t\t}\n   948|\t\tcounts[reqID] = count\n   949|\t}\n   950|\treturn counts, rows.Err()\n   951|}\n   952|\n   953|// EntryMoveSort updates parent_id, depth, and sort_order for an entry (no optimistic locking).\n   954|func EntryMoveSort(db *DB, entryID, parentID string, depth, sortOrder int) error {\n   955|\tnow := time.Now().UnixMilli()\n   956|\t_, err := db.Conn.Exec(\n   957|\t\t`UPDATE entries SET parent_id=?, depth=?, sort_order=?, updated_at=?\n   958|\t\t WHERE entry_id = ? AND deleted_at IS NULL`,\n   959|\t\tparentID, depth, sortOrder, now, entryID,\n   960|\t)\n   961|\treturn err\n   962|}\n   963|\n   964|// RenumberSiblings assigns sequential sort_order values to children of a parent.\n   965|func RenumberSiblings(db *DB, parentID string) error {\n   966|\trows, err := db.Conn.Query(\n   967|\t\t`SELECT entry_id FROM entries\n   968|\t\t WHERE parent_id = ? AND deleted_at IS NULL\n   969|\t\t ORDER BY sort_order ASC, created_at ASC`, parentID,\n   970|\t)\n   971|\tif err != nil {\n   972|\t\treturn err\n   973|\t}\n   974|\tdefer rows.Close()\n   975|\n   976|\tvar ids []string\n   977|\tfor rows.Next() {\n   978|\t\tvar id string\n   979|\t\tif err := rows.Scan(\u0026id); err != nil {\n   980|\t\t\treturn err\n   981|\t\t}\n   982|\t\tids = append(ids, id)\n   983|\t}\n   984|\tif err := rows.Err(); err != nil {\n   985|\t\treturn err\n   986|\t}\n   987|\n   988|\tfor i, id := range ids {\n   989|\t\t_, err := db.Conn.Exec(\n   990|\t\t\t`UPDATE entries SET sort_order = ? WHERE entry_id = ?`,\n   991|\t\t\t(i+1)*1000, id,\n   992|\t\t)\n   993|\t\tif err != nil {\n   994|\t\t\treturn err\n   995|\t\t}\n   996|\t}\n   997|\treturn nil\n   998|}\n   999|\n  1000|// SoftDeleteTree recursively soft-deletes an entry and all its descendants.\n  1001|func SoftDeleteTree(db *DB, entryID, actorID string) error {\n  1002|\tnow := time.Now().UnixMilli()\n  1003|\t// Delete the root entry\n  1004|\tif err := entrySoftDelete(db, entryID, actorID, now); err != nil \u0026\u0026 err != ErrNotFound {\n  1005|\t\treturn err\n  1006|\t}\n  1007|\t// Delete all descendants\n  1008|\tfor {\n  1009|\t\tresult, err := db.Conn.Exec(\n  1010|\t\t\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ?\n  1011|\t\t\t WHERE deleted_at IS NULL AND parent_id IN (\n  1012|\t\t\t   SELECT entry_id FROM entries WHERE deleted_at = ? AND deleted_by = ?\n  1013|\t\t\t )`,\n  1014|\t\t\tnow, actorID, now, now, actorID,\n  1015|\t\t)\n  1016|\t\tif err != nil {\n  1017|\t\t\treturn err\n  1018|\t\t}\n  1019|\t\taffected, _ := result.RowsAffected()\n  1020|\t\tif affected == 0 {\n  1021|\t\t\tbreak\n  1022|\t\t}\n  1023|\t}\n  1024|\treturn nil\n  1025|}\n  1026|\n  1027|// --- OAuth ---\n  1028|\n  1029|// OAuthClientByID looks up an OAuth client by its client_id.\n  1030|func OAuthClientByID(db *DB, clientID string) (*OAuthClient, error) {\n  1031|\trow := db.Conn.QueryRow(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id = ?`, clientID)\n  1032|\tvar c OAuthClient\n  1033|\tvar uris string\n  1034|\tif err := row.Scan(\u0026c.ClientID, \u0026c.ClientName, \u0026uris, \u0026c.CreatedAt); err != nil {\n  1035|\t\treturn nil, err\n  1036|\t}\n  1037|\t_ = json.Unmarshal([]byte(uris), \u0026c.RedirectURIs)\n  1038|\treturn \u0026c, nil\n  1039|}\n  1040|\n  1041|// OAuthClientCreate inserts a new OAuth client (skips if already exists).\n  1042|func OAuthClientCreate(db *DB, c *OAuthClient) error {\n  1043|\turis, _ := json.Marshal(c.RedirectURIs)\n  1044|\t_, err := db.Conn.Exec(\n  1045|\t\t`INSERT OR IGNORE INTO oauth_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,\n  1046|\t\tc.ClientID, c.ClientName, string(uris), c.CreatedAt,\n  1047|\t)\n  1048|\treturn err\n  1049|}\n  1050|\n  1051|// OAuthCodeCreate stores an authorization code.\n  1052|func OAuthCodeCreate(db *DB, code *OAuthCode) error {\n  1053|\tused := 0\n  1054|\tif code.Used {\n  1055|\t\tused = 1\n  1056|\t}\n  1057|\t_, err := db.Conn.Exec(\n  1058|\t\t`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, code_challenge, scope, expires_at, used) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\n  1059|\t\tcode.Code, code.ClientID, code.UserID, code.RedirectURI, code.CodeChallenge, code.Scope, code.ExpiresAt, used,\n  1060|\t)\n  1061|\treturn err\n  1062|}\n  1063|\n  1064|// OAuthCodeConsume looks up and marks an authorization code as used. Returns nil if invalid/expired/used.\n  1065|func OAuthCodeConsume(db *DB, codeStr string) (*OAuthCode, error) {\n  1066|\trow := db.Conn.QueryRow(\n  1067|\t\t`SELECT code, client_id, user_id, redirect_uri, code_challenge, scope, expires_at, used FROM oauth_codes WHERE code = ?`,\n  1068|\t\tcodeStr,\n  1069|\t)\n  1070|\tvar c OAuthCode\n  1071|\tvar used int\n  1072|\tif err := row.Scan(\u0026c.Code, \u0026c.ClientID, \u0026c.UserID, \u0026c.RedirectURI, \u0026c.CodeChallenge, \u0026c.Scope, \u0026c.ExpiresAt, \u0026used); err != nil {\n  1073|\t\treturn nil, err\n  1074|\t}\n  1075|\tif used == 1 || c.ExpiresAt \u003c time.Now().UnixMilli() {\n  1076|\t\treturn nil, ErrAccessDenied\n  1077|\t}\n  1078|\t// Mark as used\n  1079|\t_, err := db.Conn.Exec(`UPDATE oauth_codes SET used = 1 WHERE code = ?`, codeStr)\n  1080|\tif err != nil {\n  1081|\t\treturn nil, err\n  1082|\t}\n  1083|\treturn \u0026c, nil\n  1084|}\n  1085|\n  1086|// OAuthTokenCreate stores an access token.\n  1087|func OAuthTokenCreate(db *DB, t *OAuthToken) error {\n  1088|\trevoked := 0\n  1089|\tif t.Revoked {\n  1090|\t\trevoked = 1\n  1091|\t}\n  1092|\t_, err := db.Conn.Exec(\n  1093|\t\t`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,\n  1094|\t\tt.Token, t.ClientID, t.UserID, t.Scope, t.ExpiresAt, revoked, t.CreatedAt,\n  1095|\t)\n  1096|\treturn err\n  1097|}\n  1098|\n  1099|// OAuthTokenValidate returns a token if it's valid (not expired, not revoked).\n  1100|func OAuthTokenValidate(db *DB, tokenStr string) (*OAuthToken, error) {\n  1101|\trow := db.Conn.QueryRow(\n  1102|\t\t`SELECT token, client_id, user_id, scope, expires_at, revoked, created_at FROM oauth_tokens WHERE token = ?`,\n  1103|\t\ttokenStr,\n  1104|\t)\n  1105|\tvar t OAuthToken\n  1106|\tvar revoked int\n  1107|\tif err := row.Scan(\u0026t.Token, \u0026t.ClientID, \u0026t.UserID, \u0026t.Scope, \u0026t.ExpiresAt, \u0026revoked, \u0026t.CreatedAt); err != nil {\n  1108|\t\treturn nil, err\n  1109|\t}\n  1110|\tt.Revoked = revoked == 1\n  1111|\tif t.Revoked || t.ExpiresAt \u003c time.Now().UnixMilli() {\n  1112|\t\treturn nil, ErrAccessDenied\n  1113|\t}\n  1114|\treturn \u0026t, nil\n  1115|}\n  1116|\n  1117|// OAuthTokenRevoke marks a token as revoked.\n  1118|func OAuthTokenRevoke(db *DB, tokenStr string) error {\n  1119|\t_, err := db.Conn.Exec(`UPDATE oauth_tokens SET revoked = 1 WHERE token = ?`, tokenStr)\n  1120|\treturn err\n  1121|}\n  1122|\n  1123|// SetSessionTestRole updates the test_role on a session (super-admin only).\n  1124|// ListTemplates returns all global request templates.\n  1125|func ListTemplates(db *DB, cfg *Config) ([]Entry, error) {\n  1126|\tfilter := EntryFilter{\n  1127|\t\tProjectID: TemplateProjectID,\n  1128|\t\tType:      TypeRequestTemplate,\n  1129|\t}\n  1130|\tentries, err := entryQuery(db, filter)\n  1131|\tif err != nil {\n  1132|\t\treturn nil, err\n  1133|\t}\n  1134|\t// Use system key for template project\n  1135|\ttemplateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)\n  1136|\tif err != nil {\n  1137|\t\treturn nil, err\n  1138|\t}\n  1139|\tfor i := range entries {\n  1140|\t\tif err := unpackEntryWithKey(templateKey, \u0026entries[i]); err != nil {\n  1141|\t\t\tcontinue\n  1142|\t\t}\n  1143|\t}\n  1144|\treturn entries, nil\n  1145|}\n  1146|\n  1147|// TemplateByID returns a single request template by entry_id.\n  1148|func TemplateByID(db *DB, cfg *Config, entryID string) (*Entry, error) {\n  1149|\te, err := entryReadSystem(db, entryID)\n  1150|\tif err != nil || e == nil {\n  1151|\t\treturn e, err\n  1152|\t}\n  1153|\ttemplateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)\n  1154|\tif err != nil {\n  1155|\t\treturn nil, err\n  1156|\t}\n  1157|\tif err := unpackEntryWithKey(templateKey, e); err != nil {\n  1158|\t\treturn nil, err\n  1159|\t}\n  1160|\treturn e, nil\n  1161|}\n  1162|\n  1163|func SetSessionTestRole(db *DB, sessionID, role string) error {\n  1164|\t_, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID)\n  1165|\treturn err\n  1166|}\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/lib/dbcore.go\",\"content\":\"package lib\\n\\nimport (\\n\\t\\\"crypto/subtle\\\"\\n\\t\\\"database/sql\\\"\\n\\t\\\"encoding/json\\\"\\n\\t\\\"errors\\\"\\n\\t\\\"fmt\\\"\\n\\t\\\"os\\\"\\n\\t\\\"path/filepath\\\"\\n\\t\\\"sort\\\"\\n\\t\\\"strings\\\"\\n\\t\\\"time\\\"\\n\\n\\t\\\"github.com/google/uuid\\\"\\n\\t_ \\\"github.com/mattn/go-sqlite3\\\"\\n)\\n\\nvar (\\n\\tErrNotFound         = errors.New(\\\"not found\\\")\\n\\tErrVersionConflict  = errors.New(\\\"version conflict: entry was modified by another request\\\")\\n\\tErrSoftDeleted      = errors.New(\\\"entry has been deleted\\\")\\n)\\n\\n// OpenDB opens (or creates) the SQLite database and runs migrations.\\nfunc OpenDB(dbPath string, migrationPath string) (*DB, error) {\\n\\tconn, err := sql.Open(\\\"sqlite3\\\", dbPath+\\\"?_journal_mode=WAL\\u0026_foreign_keys=ON\\u0026_busy_timeout=5000\\\")\\n\\tif err != nil {\\n\\t\\treturn nil, fmt.Errorf(\\\"open db: %w\\\", err)\\n\\t}\\n\\n\\tif err := conn.Ping(); err != nil {\\n\\t\\treturn nil, fmt.Errorf(\\\"ping db: %w\\\", err)\\n\\t}\\n\\n\\tdb := \\u0026DB{Conn: conn}\\n\\n\\tif migrationPath != \\\"\\\" {\\n\\t\\tif err := db.runMigration(migrationPath); err != nil {\\n\\t\\t\\treturn nil, fmt.Errorf(\\\"migration: %w\\\", err)\\n\\t\\t}\\n\\t}\\n\\n\\treturn db, nil\\n}\\n\\nfunc (db *DB) runMigration(path string) error {\\n\\tinfo, err := os.Stat(path)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\n\\t// If path is a directory, run all *.sql files sorted\\n\\tif info.IsDir() {\\n\\t\\tentries, err := os.ReadDir(path)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\tvar files []string\\n\\t\\tfor _, e := range entries {\\n\\t\\t\\tif !e.IsDir() \\u0026\\u0026 strings.HasSuffix(e.Name(), \\\".sql\\\") {\\n\\t\\t\\t\\tfiles = append(files, e.Name())\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tsort.Strings(files)\\n\\t\\tfor _, f := range files {\\n\\t\\t\\tif err := db.runSingleMigration(filepath.Join(path, f)); err != nil {\\n\\t\\t\\t\\treturn fmt.Errorf(\\\"%s: %w\\\", f, err)\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\treturn nil\\n\\t}\\n\\n\\treturn db.runSingleMigration(path)\\n}\\n\\nfunc (db *DB) runSingleMigration(path string) error {\\n\\tsqlBytes, err := os.ReadFile(path)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\t_, err = db.Conn.Exec(string(sqlBytes))\\n\\tif err != nil {\\n\\t\\t// Gracefully handle \\\"duplicate column\\\" for ALTER TABLE migrations\\n\\t\\tif strings.Contains(err.Error(), \\\"duplicate column\\\") {\\n\\t\\t\\treturn nil\\n\\t\\t}\\n\\t\\treturn err\\n\\t}\\n\\treturn nil\\n}\\n\\n// Close closes the database connection.\\nfunc (db *DB) Close() error {\\n\\treturn db.Conn.Close()\\n}\\n\\n// ---------------------------------------------------------------------------\\n// THE THREE CHOKE POINTS — all entry access goes through these\\n// ---------------------------------------------------------------------------\\n\\n// EntryRead queries entries with RBAC enforcement.\\n// Buyers cannot see pre_dataroom entries.\\nfunc EntryRead(db *DB, cfg *Config, actorID, projectID string, filter EntryFilter) ([]Entry, error) {\\n\\t// Check read access\\n\\trole, err := GetUserHighestRole(db, actorID, projectID)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\n\\tentries, err := entryQuery(db, filter)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\n\\t// Filter: buyers cannot see pre_dataroom entries\\n\\tif IsBuyerRole(role) {\\n\\t\\tfiltered := entries[:0]\\n\\t\\tfor _, e := range entries {\\n\\t\\t\\tif e.Stage != StagePreDataroom {\\n\\t\\t\\t\\tfiltered = append(filtered, e)\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tentries = filtered\\n\\t}\\n\\n\\t// Unpack encrypted fields\\n\\tfor i := range entries {\\n\\t\\tif err := unpackEntry(cfg, \\u0026entries[i]); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t}\\n\\n\\treturn entries, nil\\n}\\n\\n// EntryWrite creates or updates entries with RBAC enforcement and optimistic locking.\\nfunc EntryWrite(db *DB, cfg *Config, actorID string, entries ...*Entry) error {\\n\\tnow := time.Now().UnixMilli()\\n\\n\\tfor _, entry := range entries {\\n\\t\\t// Check write access\\n\\t\\tif err := CheckAccessWrite(db, actorID, entry.ProjectID, \\\"\\\"); err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\n\\t\\t// Pack encrypted fields\\n\\t\\tif err := packEntry(cfg, entry); err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\n\\t\\tif entry.EntryID == \\\"\\\" {\\n\\t\\t\\t// New entry\\n\\t\\t\\tentry.EntryID = uuid.New().String()\\n\\t\\t\\tentry.CreatedBy = actorID\\n\\t\\t\\tentry.CreatedAt = now\\n\\t\\t\\tentry.UpdatedAt = now\\n\\t\\t\\tentry.Version = 1\\n\\t\\t\\tentry.KeyVersion = 1\\n\\n\\t\\t\\tif err := entryInsert(db, entry); err != nil {\\n\\t\\t\\t\\treturn err\\n\\t\\t\\t}\\n\\t\\t} else {\\n\\t\\t\\t// Update with optimistic locking\\n\\t\\t\\tentry.UpdatedAt = now\\n\\t\\t\\tif err := entryUpdate(db, entry); err != nil {\\n\\t\\t\\t\\treturn err\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\treturn nil\\n}\\n\\n// EntryDelete soft-deletes entries with RBAC enforcement.\\nfunc EntryDelete(db *DB, actorID, projectID string, entryIDs ...string) error {\\n\\tif err := CheckAccessDelete(db, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\treturn err\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\tfor _, id := range entryIDs {\\n\\t\\tif err := entrySoftDelete(db, id, actorID, now); err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t}\\n\\treturn nil\\n}\\n\\n// entryReadSystem is the unexported system bypass — NO RBAC checks.\\n// Used internally for tree walks (e.g., ResolveWorkstreamID).\\nfunc entryReadSystem(db *DB, entryID string) (*Entry, error) {\\n\\trow := db.Conn.QueryRow(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2,\\n\\t\\t        summary, data, stage, assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by\\n\\t\\t FROM entries WHERE entry_id = ?`, entryID,\\n\\t)\\n\\treturn scanEntry(row)\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Internal SQL — never exported, never called outside this file\\n// ---------------------------------------------------------------------------\\n\\nfunc entryQuery(db *DB, f EntryFilter) ([]Entry, error) {\\n\\tq := `SELECT entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2,\\n\\t             summary, data, stage, assignee_id, return_to_id, origin_id,\\n\\t             version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by\\n\\t      FROM entries WHERE deleted_at IS NULL`\\n\\tvar args []any\\n\\n\\tif f.ProjectID != \\\"\\\" {\\n\\t\\tq += \\\" AND project_id = ?\\\"\\n\\t\\targs = append(args, f.ProjectID)\\n\\t}\\n\\tif f.ParentID != nil {\\n\\t\\tq += \\\" AND parent_id = ?\\\"\\n\\t\\targs = append(args, *f.ParentID)\\n\\t}\\n\\tif f.Type != \\\"\\\" {\\n\\t\\tq += \\\" AND type = ?\\\"\\n\\t\\targs = append(args, f.Type)\\n\\t}\\n\\tif f.Stage != \\\"\\\" {\\n\\t\\tq += \\\" AND stage = ?\\\"\\n\\t\\targs = append(args, f.Stage)\\n\\t}\\n\\tif f.AssigneeID != \\\"\\\" {\\n\\t\\tq += \\\" AND assignee_id = ?\\\"\\n\\t\\targs = append(args, f.AssigneeID)\\n\\t}\\n\\tif f.SearchKey != nil {\\n\\t\\tq += \\\" AND search_key = ?\\\"\\n\\t\\targs = append(args, f.SearchKey)\\n\\t}\\n\\n\\tq += \\\" ORDER BY sort_order ASC, created_at ASC\\\"\\n\\n\\tif f.Limit \\u003e 0 {\\n\\t\\tq += \\\" LIMIT ?\\\"\\n\\t\\targs = append(args, f.Limit)\\n\\t}\\n\\tif f.Offset \\u003e 0 {\\n\\t\\tq += \\\" OFFSET ?\\\"\\n\\t\\targs = append(args, f.Offset)\\n\\t}\\n\\n\\trows, err := db.Conn.Query(q, args...)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []Entry\\n\\tfor rows.Next() {\\n\\t\\te, err := scanEntryRow(rows)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, *e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\nfunc entryInsert(db *DB, e *Entry) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\te.EntryID, e.ProjectID, e.ParentID, e.Type, e.Depth, e.SortOrder,\\n\\t\\te.SearchKey, e.SearchKey2, e.Summary, e.Data, e.Stage,\\n\\t\\te.AssigneeID, e.ReturnToID, e.OriginID,\\n\\t\\te.Version, e.DeletedAt, e.DeletedBy, e.KeyVersion,\\n\\t\\te.CreatedAt, e.UpdatedAt, e.CreatedBy,\\n\\t)\\n\\treturn err\\n}\\n\\nfunc entryUpdate(db *DB, e *Entry) error {\\n\\tresult, err := db.Conn.Exec(\\n\\t\\t`UPDATE entries SET\\n\\t\\t  parent_id=?, type=?, depth=?, sort_order=?, search_key=?, search_key2=?,\\n\\t\\t  summary=?, data=?, stage=?,\\n\\t\\t  assignee_id=?, return_to_id=?, origin_id=?,\\n\\t\\t  version=version+1, updated_at=?\\n\\t\\t WHERE entry_id = ? AND version = ? AND deleted_at IS NULL`,\\n\\t\\te.ParentID, e.Type, e.Depth, e.SortOrder, e.SearchKey, e.SearchKey2,\\n\\t\\te.Summary, e.Data, e.Stage,\\n\\t\\te.AssigneeID, e.ReturnToID, e.OriginID,\\n\\t\\te.UpdatedAt,\\n\\t\\te.EntryID, e.Version,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\taffected, err := result.RowsAffected()\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\tif affected == 0 {\\n\\t\\treturn ErrVersionConflict\\n\\t}\\n\\te.Version++\\n\\treturn nil\\n}\\n\\nfunc entrySoftDelete(db *DB, entryID, actorID string, now int64) error {\\n\\tresult, err := db.Conn.Exec(\\n\\t\\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ?\\n\\t\\t WHERE entry_id = ? AND deleted_at IS NULL`,\\n\\t\\tnow, actorID, now, entryID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\taffected, err := result.RowsAffected()\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\tif affected == 0 {\\n\\t\\treturn ErrNotFound\\n\\t}\\n\\treturn nil\\n}\\n\\n// scanEntry scans a single row into an Entry.\\nfunc scanEntry(row *sql.Row) (*Entry, error) {\\n\\tvar e Entry\\n\\terr := row.Scan(\\n\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t)\\n\\tif errors.Is(err, sql.ErrNoRows) {\\n\\t\\treturn nil, nil\\n\\t}\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\treturn \\u0026e, nil\\n}\\n\\n// scanEntryRow scans a row from sql.Rows into an Entry.\\nfunc scanEntryRow(rows *sql.Rows) (*Entry, error) {\\n\\tvar e Entry\\n\\terr := rows.Scan(\\n\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\treturn \\u0026e, nil\\n}\\n\\n// packEntry encrypts the text fields of an entry before storage.\\nfunc packEntry(cfg *Config, e *Entry) error {\\n\\tkey, err := DeriveProjectKey(cfg.MasterKey, e.ProjectID)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\n\\tif e.SummaryText != \\\"\\\" {\\n\\t\\tpacked, err := Pack(key, e.SummaryText)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.Summary = packed\\n\\t}\\n\\n\\tif e.DataText != \\\"\\\" {\\n\\t\\tpacked, err := Pack(key, e.DataText)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.Data = packed\\n\\t}\\n\\n\\treturn nil\\n}\\n\\n// unpackEntry decrypts the packed fields of an entry after read.\\nfunc unpackEntry(cfg *Config, e *Entry) error {\\n\\tkey, err := DeriveProjectKey(cfg.MasterKey, e.ProjectID)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\n\\tif len(e.Summary) \\u003e 0 {\\n\\t\\ttext, err := Unpack(key, e.Summary)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.SummaryText = text\\n\\t}\\n\\n\\tif len(e.Data) \\u003e 0 {\\n\\t\\ttext, err := Unpack(key, e.Data)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.DataText = text\\n\\t}\\n\\n\\treturn nil\\n}\\n\\n// unpackEntryWithKey decrypts an entry using a pre-derived key (used for templates).\\nfunc unpackEntryWithKey(key []byte, e *Entry) error {\\n\\tif len(e.Summary) \\u003e 0 {\\n\\t\\ttext, err := Unpack(key, e.Summary)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.SummaryText = text\\n\\t}\\n\\tif len(e.Data) \\u003e 0 {\\n\\t\\ttext, err := Unpack(key, e.Data)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\te.DataText = text\\n\\t}\\n\\treturn nil\\n}\\n\\n// ---------------------------------------------------------------------------\\n// User operations\\n// ---------------------------------------------------------------------------\\n\\n// UserCreate inserts a new user.\\nfunc UserCreate(db *DB, u *User) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO users (user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at)\\n\\t\\t VALUES (?,?,?,?,?,?,?,?,?,?)`,\\n\\t\\tu.UserID, u.Email, u.Name, u.Password, u.OrgID, u.OrgName, u.MFASecret, u.Active, u.CreatedAt, u.UpdatedAt,\\n\\t)\\n\\treturn err\\n}\\n\\n// UserByEmail looks up a user by email.\\nfunc UserByEmail(db *DB, email string) (*User, error) {\\n\\tvar u User\\n\\tvar active int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\\n\\t\\t FROM users WHERE email = ?`, email,\\n\\t).Scan(\\u0026u.UserID, \\u0026u.Email, \\u0026u.Name, \\u0026u.Password, \\u0026u.OrgID, \\u0026u.OrgName, \\u0026u.MFASecret, \\u0026active, \\u0026u.CreatedAt, \\u0026u.UpdatedAt)\\n\\tif errors.Is(err, sql.ErrNoRows) {\\n\\t\\treturn nil, nil\\n\\t}\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tu.Active = active == 1\\n\\treturn \\u0026u, nil\\n}\\n\\n// UserByID looks up a user by ID.\\nfunc UserByID(db *DB, userID string) (*User, error) {\\n\\tvar u User\\n\\tvar active int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\\n\\t\\t FROM users WHERE user_id = ?`, userID,\\n\\t).Scan(\\u0026u.UserID, \\u0026u.Email, \\u0026u.Name, \\u0026u.Password, \\u0026u.OrgID, \\u0026u.OrgName, \\u0026u.MFASecret, \\u0026active, \\u0026u.CreatedAt, \\u0026u.UpdatedAt)\\n\\tif errors.Is(err, sql.ErrNoRows) {\\n\\t\\treturn nil, nil\\n\\t}\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tu.Active = active == 1\\n\\treturn \\u0026u, nil\\n}\\n\\n// UserCount returns the number of users in the database.\\nfunc UserCount(db *DB) (int, error) {\\n\\tvar count int\\n\\terr := db.Conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(\\u0026count)\\n\\treturn count, err\\n}\\n\\n// ProjectsByUser returns all projects a user has access to.\\nfunc ProjectsByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth, e.sort_order,\\n\\t\\t        e.search_key, e.search_key2, e.summary, e.data, e.stage,\\n\\t\\t        e.assignee_id, e.return_to_id, e.origin_id,\\n\\t\\t        e.version, e.deleted_at, e.deleted_by, e.key_version,\\n\\t\\t        e.created_at, e.updated_at, e.created_by\\n\\t\\t FROM entries e\\n\\t\\t JOIN access a ON a.project_id = e.project_id\\n\\t\\t WHERE a.user_id = ? AND a.revoked_at IS NULL AND e.type = 'project' AND e.deleted_at IS NULL\\n\\t\\t ORDER BY e.updated_at DESC`, userID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []Entry\\n\\tfor rows.Next() {\\n\\t\\te, err := scanEntryRow(rows)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tif err := unpackEntry(cfg, e); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, *e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\n// TasksByUser returns all entries assigned to a user across all projects.\\nfunc TasksByUser(db *DB, cfg *Config, userID string) ([]Entry, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries\\n\\t\\t WHERE assignee_id = ? AND deleted_at IS NULL\\n\\t\\t ORDER BY created_at DESC`, userID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []Entry\\n\\tfor rows.Next() {\\n\\t\\te, err := scanEntryRow(rows)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tif err := unpackEntry(cfg, e); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, *e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\n// EntriesByParent returns entries with a given parent ID.\\nfunc EntriesByParent(db *DB, cfg *Config, parentID string) ([]Entry, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries\\n\\t\\t WHERE parent_id = ? AND deleted_at IS NULL\\n\\t\\t ORDER BY sort_order ASC, created_at ASC`, parentID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []Entry\\n\\tfor rows.Next() {\\n\\t\\te, err := scanEntryRow(rows)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tif err := unpackEntry(cfg, e); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, *e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\n// EntryByID returns a single entry by ID (with RBAC bypass for internal use).\\nfunc EntryByID(db *DB, cfg *Config, entryID string) (*Entry, error) {\\n\\te, err := entryReadSystem(db, entryID)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tif e == nil {\\n\\t\\treturn nil, nil\\n\\t}\\n\\tif err := unpackEntry(cfg, e); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\treturn e, nil\\n}\\n\\n// RequestCountByProject returns the number of requests in a project.\\nfunc RequestCountByProject(db *DB, projectID string) (int, int, error) {\\n\\tvar total, open int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL`,\\n\\t\\tprojectID,\\n\\t).Scan(\\u0026total)\\n\\tif err != nil {\\n\\t\\treturn 0, 0, err\\n\\t}\\n\\terr = db.Conn.QueryRow(\\n\\t\\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL AND stage = 'pre_dataroom'`,\\n\\t\\tprojectID,\\n\\t).Scan(\\u0026open)\\n\\treturn total, open, err\\n}\\n\\n// WorkstreamCountByProject returns the number of workstreams in a project.\\nfunc WorkstreamCountByProject(db *DB, projectID string) (int, error) {\\n\\tvar count int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT COUNT(*) FROM entries WHERE project_id = ? AND type = 'workstream' AND deleted_at IS NULL`,\\n\\t\\tprojectID,\\n\\t).Scan(\\u0026count)\\n\\treturn count, err\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Challenge operations (passwordless OTP auth)\\n// ---------------------------------------------------------------------------\\n\\n// ChallengeCreate inserts a new email challenge.\\nfunc ChallengeCreate(db *DB, c *Challenge) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO challenges (challenge_id, email, code, created_at, expires_at, used)\\n\\t\\t VALUES (?,?,?,?,?,?)`,\\n\\t\\tc.ChallengeID, c.Email, c.Code, c.CreatedAt, c.ExpiresAt, c.Used,\\n\\t)\\n\\treturn err\\n}\\n\\n// ChallengeVerify looks up the most recent unused challenge for an email and marks it used if the code matches.\\n// Returns the challenge if valid, nil if not found or invalid.\\nfunc ChallengeVerify(db *DB, email, code string) (*Challenge, error) {\\n\\tvar c Challenge\\n\\tvar used int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT challenge_id, email, code, created_at, expires_at, used\\n\\t\\t FROM challenges\\n\\t\\t WHERE email = ? AND used = 0\\n\\t\\t ORDER BY created_at DESC LIMIT 1`, email,\\n\\t).Scan(\\u0026c.ChallengeID, \\u0026c.Email, \\u0026c.Code, \\u0026c.CreatedAt, \\u0026c.ExpiresAt, \\u0026used)\\n\\tif err != nil {\\n\\t\\treturn nil, nil // no challenge found\\n\\t}\\n\\tc.Used = used == 1\\n\\n\\t// Check expiry\\n\\tif c.ExpiresAt \\u003c time.Now().UnixMilli() {\\n\\t\\treturn nil, nil\\n\\t}\\n\\n\\t// Check code - constant-time comparison to prevent timing attacks\\n\\tif subtle.ConstantTimeCompare([]byte(c.Code), []byte(code)) != 1 {\\n\\t\\treturn nil, nil\\n\\t}\\n\\n\\t// Mark as used\\n\\t_, err = db.Conn.Exec(`UPDATE challenges SET used = 1 WHERE challenge_id = ?`, c.ChallengeID)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\n\\treturn \\u0026c, nil\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Admin query helpers\\n// ---------------------------------------------------------------------------\\n\\n// AllUsers returns all users (for super admin).\\nfunc AllUsers(db *DB) ([]User, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT user_id, email, name, password, org_id, org_name, mfa_secret, active, created_at, updated_at\\n\\t\\t FROM users ORDER BY created_at DESC`,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar users []User\\n\\tfor rows.Next() {\\n\\t\\tvar u User\\n\\t\\tvar active int\\n\\t\\tif err := rows.Scan(\\u0026u.UserID, \\u0026u.Email, \\u0026u.Name, \\u0026u.Password, \\u0026u.OrgID, \\u0026u.OrgName, \\u0026u.MFASecret, \\u0026active, \\u0026u.CreatedAt, \\u0026u.UpdatedAt); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tu.Active = active == 1\\n\\t\\tusers = append(users, u)\\n\\t}\\n\\treturn users, rows.Err()\\n}\\n\\n// AllProjects returns all projects (for super admin, no RBAC).\\nfunc AllProjects(db *DB, cfg *Config) ([]Entry, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries WHERE type = 'project' AND deleted_at IS NULL\\n\\t\\t ORDER BY updated_at DESC`,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []Entry\\n\\tfor rows.Next() {\\n\\t\\te, err := scanEntryRow(rows)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tif err := unpackEntry(cfg, e); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, *e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\n// AuditRecent returns the most recent audit entries (for super admin).\\nfunc AuditRecent(db *DB, limit int) ([]AuditEntry, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT id, project_id, actor_id, action, target_id, details, ip, ts\\n\\t\\t FROM audit ORDER BY ts DESC LIMIT ?`, limit,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []AuditEntry\\n\\tfor rows.Next() {\\n\\t\\tvar a AuditEntry\\n\\t\\tvar targetID, ip *string\\n\\t\\tif err := rows.Scan(\\u0026a.ID, \\u0026a.ProjectID, \\u0026a.ActorID, \\u0026a.Action, \\u0026targetID, \\u0026a.Details, \\u0026ip, \\u0026a.Ts); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tif targetID != nil {\\n\\t\\t\\ta.TargetID = *targetID\\n\\t\\t}\\n\\t\\tif ip != nil {\\n\\t\\t\\ta.IP = *ip\\n\\t\\t}\\n\\t\\tentries = append(entries, a)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Access operations\\n// ---------------------------------------------------------------------------\\n\\n// AccessGrant creates a new access grant.\\nfunc AccessGrant(db *DB, a *Access) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO access (id, project_id, workstream_id, user_id, role, ops, can_grant, granted_by, granted_at)\\n\\t\\t VALUES (?,?,?,?,?,?,?,?,?)`,\\n\\t\\ta.ID, a.ProjectID, a.WorkstreamID, a.UserID, a.Role, a.Ops, a.CanGrant, a.GrantedBy, a.GrantedAt,\\n\\t)\\n\\treturn err\\n}\\n\\n// AccessRevoke soft-revokes an access grant.\\nfunc AccessRevoke(db *DB, accessID, revokedBy string) error {\\n\\tnow := time.Now().UnixMilli()\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`UPDATE access SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL`,\\n\\t\\tnow, revokedBy, accessID,\\n\\t)\\n\\treturn err\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Session operations\\n// ---------------------------------------------------------------------------\\n\\n// SessionCreate inserts a new session.\\nfunc SessionCreate(db *DB, s *Session) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO sessions (id, user_id, fingerprint, created_at, expires_at, revoked)\\n\\t\\t VALUES (?,?,?,?,?,?)`,\\n\\t\\ts.ID, s.UserID, s.Fingerprint, s.CreatedAt, s.ExpiresAt, s.Revoked,\\n\\t)\\n\\treturn err\\n}\\n\\n// SessionByID retrieves a session.\\nfunc SessionByID(db *DB, sessionID string) (*Session, error) {\\n\\tvar s Session\\n\\tvar revoked int\\n\\terr := db.Conn.QueryRow(\\n\\t\\t`SELECT id, user_id, fingerprint, created_at, expires_at, revoked\\n\\t\\t FROM sessions WHERE id = ?`, sessionID,\\n\\t).Scan(\\u0026s.ID, \\u0026s.UserID, \\u0026s.Fingerprint, \\u0026s.CreatedAt, \\u0026s.ExpiresAt, \\u0026revoked)\\n\\tif errors.Is(err, sql.ErrNoRows) {\\n\\t\\treturn nil, nil\\n\\t}\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\ts.Revoked = revoked == 1\\n\\treturn \\u0026s, nil\\n}\\n\\n// SessionRevoke revokes a session.\\nfunc SessionRevoke(db *DB, sessionID string) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`UPDATE sessions SET revoked = 1 WHERE id = ?`, sessionID,\\n\\t)\\n\\treturn err\\n}\\n\\n// SessionRevokeAllForUser revokes all sessions for a user.\\nfunc SessionRevokeAllForUser(db *DB, userID string) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`UPDATE sessions SET revoked = 1 WHERE user_id = ? AND revoked = 0`, userID,\\n\\t)\\n\\treturn err\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Audit operations\\n// ---------------------------------------------------------------------------\\n\\n// AuditLog records a security event.\\nfunc AuditLog(db *DB, cfg *Config, a *AuditEntry) error {\\n\\tif a.ID == \\\"\\\" {\\n\\t\\ta.ID = uuid.New().String()\\n\\t}\\n\\tif a.Ts == 0 {\\n\\t\\ta.Ts = time.Now().UnixMilli()\\n\\t}\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO audit (id, project_id, actor_id, action, target_id, details, ip, ts)\\n\\t\\t VALUES (?,?,?,?,?,?,?,?)`,\\n\\t\\ta.ID, a.ProjectID, a.ActorID, a.Action, a.TargetID, a.Details, a.IP, a.Ts,\\n\\t)\\n\\treturn err\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Entry event operations\\n// ---------------------------------------------------------------------------\\n\\n// EntryEventCreate records a workflow event on an entry.\\nfunc EntryEventCreate(db *DB, ev *EntryEvent) error {\\n\\tif ev.ID == \\\"\\\" {\\n\\t\\tev.ID = uuid.New().String()\\n\\t}\\n\\tif ev.Ts == 0 {\\n\\t\\tev.Ts = time.Now().UnixMilli()\\n\\t}\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO entry_events (id, entry_id, actor_id, channel, action, data, ts)\\n\\t\\t VALUES (?,?,?,?,?,?,?)`,\\n\\t\\tev.ID, ev.EntryID, ev.ActorID, ev.Channel, ev.Action, ev.Data, ev.Ts,\\n\\t)\\n\\treturn err\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Answer link operations\\n// ---------------------------------------------------------------------------\\n\\n// AnswerLinkCreate links an answer to a request.\\nfunc AnswerLinkCreate(db *DB, answerID, requestID, linkedBy string) error {\\n\\tnow := time.Now().UnixMilli()\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT OR IGNORE INTO answer_links (answer_id, request_id, linked_by, linked_at, confirmed, status)\\n\\t\\t VALUES (?,?,?,?,0,'pending')`,\\n\\t\\tanswerID, requestID, linkedBy, now,\\n\\t)\\n\\treturn err\\n}\\n\\n// AnswerLinksByRequest returns all answer links for a request.\\nfunc AnswerLinksByRequest(db *DB, requestID string) ([]AnswerLink, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT answer_id, request_id, linked_by, linked_at, confirmed, ai_score, status,\\n\\t\\t        reviewed_by, reviewed_at, reject_reason\\n\\t\\t FROM answer_links WHERE request_id = ?\\n\\t\\t ORDER BY linked_at ASC`, requestID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar links []AnswerLink\\n\\tfor rows.Next() {\\n\\t\\tvar l AnswerLink\\n\\t\\tif err := rows.Scan(\\u0026l.AnswerID, \\u0026l.RequestID, \\u0026l.LinkedBy, \\u0026l.LinkedAt,\\n\\t\\t\\t\\u0026l.Confirmed, \\u0026l.AIScore, \\u0026l.Status,\\n\\t\\t\\t\\u0026l.ReviewedBy, \\u0026l.ReviewedAt, \\u0026l.RejectReason); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tlinks = append(links, l)\\n\\t}\\n\\treturn links, rows.Err()\\n}\\n\\n// AnswerLinkDelete removes a link between an answer and a request.\\nfunc AnswerLinkDelete(db *DB, answerID, requestID string) error {\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`DELETE FROM answer_links WHERE answer_id = ? AND request_id = ?`,\\n\\t\\tanswerID, requestID,\\n\\t)\\n\\treturn err\\n}\\n\\n// AnswerLinkCountsByProject returns a map of request_id → count of linked answers.\\nfunc AnswerLinkCountsByProject(db *DB, projectID string) (map[string]int, error) {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT al.request_id, COUNT(*)\\n\\t\\t FROM answer_links al\\n\\t\\t JOIN entries e ON e.entry_id = al.request_id\\n\\t\\t WHERE e.project_id = ? AND e.deleted_at IS NULL\\n\\t\\t GROUP BY al.request_id`, projectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tcounts := make(map[string]int)\\n\\tfor rows.Next() {\\n\\t\\tvar reqID string\\n\\t\\tvar count int\\n\\t\\tif err := rows.Scan(\\u0026reqID, \\u0026count); err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tcounts[reqID] = count\\n\\t}\\n\\treturn counts, rows.Err()\\n}\\n\\n// EntryMoveSort updates parent_id, depth, and sort_order for an entry (no optimistic locking).\\nfunc EntryMoveSort(db *DB, entryID, parentID string, depth, sortOrder int) error {\\n\\tnow := time.Now().UnixMilli()\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`UPDATE entries SET parent_id=?, depth=?, sort_order=?, updated_at=?\\n\\t\\t WHERE entry_id = ? AND deleted_at IS NULL`,\\n\\t\\tparentID, depth, sortOrder, now, entryID,\\n\\t)\\n\\treturn err\\n}\\n\\n// RenumberSiblings assigns sequential sort_order values to children of a parent.\\nfunc RenumberSiblings(db *DB, parentID string) error {\\n\\trows, err := db.Conn.Query(\\n\\t\\t`SELECT entry_id FROM entries\\n\\t\\t WHERE parent_id = ? AND deleted_at IS NULL\\n\\t\\t ORDER BY sort_order ASC, created_at ASC`, parentID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar ids []string\\n\\tfor rows.Next() {\\n\\t\\tvar id string\\n\\t\\tif err := rows.Scan(\\u0026id); err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\tids = append(ids, id)\\n\\t}\\n\\tif err := rows.Err(); err != nil {\\n\\t\\treturn err\\n\\t}\\n\\n\\tfor i, id := range ids {\\n\\t\\t_, err := db.Conn.Exec(\\n\\t\\t\\t`UPDATE entries SET sort_order = ? WHERE entry_id = ?`,\\n\\t\\t\\t(i+1)*1000, id,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t}\\n\\treturn nil\\n}\\n\\n// SoftDeleteTree recursively soft-deletes an entry and all its descendants.\\nfunc SoftDeleteTree(db *DB, entryID, actorID string) error {\\n\\tnow := time.Now().UnixMilli()\\n\\t// Delete the root entry\\n\\tif err := entrySoftDelete(db, entryID, actorID, now); err != nil \\u0026\\u0026 err != ErrNotFound {\\n\\t\\treturn err\\n\\t}\\n\\t// Delete all descendants\\n\\tfor {\\n\\t\\tresult, err := db.Conn.Exec(\\n\\t\\t\\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ?\\n\\t\\t\\t WHERE deleted_at IS NULL AND parent_id IN (\\n\\t\\t\\t   SELECT entry_id FROM entries WHERE deleted_at = ? AND deleted_by = ?\\n\\t\\t\\t )`,\\n\\t\\t\\tnow, actorID, now, now, actorID,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn err\\n\\t\\t}\\n\\t\\taffected, _ := result.RowsAffected()\\n\\t\\tif affected == 0 {\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\treturn nil\\n}\\n\\n// --- OAuth ---\\n\\n// OAuthClientByID looks up an OAuth client by its client_id.\\nfunc OAuthClientByID(db *DB, clientID string) (*OAuthClient, error) {\\n\\trow := db.Conn.QueryRow(`SELECT client_id, client_name, redirect_uris, created_at FROM oauth_clients WHERE client_id = ?`, clientID)\\n\\tvar c OAuthClient\\n\\tvar uris string\\n\\tif err := row.Scan(\\u0026c.ClientID, \\u0026c.ClientName, \\u0026uris, \\u0026c.CreatedAt); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\t_ = json.Unmarshal([]byte(uris), \\u0026c.RedirectURIs)\\n\\treturn \\u0026c, nil\\n}\\n\\n// OAuthClientCreate inserts a new OAuth client (skips if already exists).\\nfunc OAuthClientCreate(db *DB, c *OAuthClient) error {\\n\\turis, _ := json.Marshal(c.RedirectURIs)\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT OR IGNORE INTO oauth_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,\\n\\t\\tc.ClientID, c.ClientName, string(uris), c.CreatedAt,\\n\\t)\\n\\treturn err\\n}\\n\\n// OAuthCodeCreate stores an authorization code.\\nfunc OAuthCodeCreate(db *DB, code *OAuthCode) error {\\n\\tused := 0\\n\\tif code.Used {\\n\\t\\tused = 1\\n\\t}\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, code_challenge, scope, expires_at, used) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,\\n\\t\\tcode.Code, code.ClientID, code.UserID, code.RedirectURI, code.CodeChallenge, code.Scope, code.ExpiresAt, used,\\n\\t)\\n\\treturn err\\n}\\n\\n// OAuthCodeConsume looks up and marks an authorization code as used. Returns nil if invalid/expired/used.\\nfunc OAuthCodeConsume(db *DB, codeStr string) (*OAuthCode, error) {\\n\\trow := db.Conn.QueryRow(\\n\\t\\t`SELECT code, client_id, user_id, redirect_uri, code_challenge, scope, expires_at, used FROM oauth_codes WHERE code = ?`,\\n\\t\\tcodeStr,\\n\\t)\\n\\tvar c OAuthCode\\n\\tvar used int\\n\\tif err := row.Scan(\\u0026c.Code, \\u0026c.ClientID, \\u0026c.UserID, \\u0026c.RedirectURI, \\u0026c.CodeChallenge, \\u0026c.Scope, \\u0026c.ExpiresAt, \\u0026used); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tif used == 1 || c.ExpiresAt \\u003c time.Now().UnixMilli() {\\n\\t\\treturn nil, ErrAccessDenied\\n\\t}\\n\\t// Mark as used\\n\\t_, err := db.Conn.Exec(`UPDATE oauth_codes SET used = 1 WHERE code = ?`, codeStr)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\treturn \\u0026c, nil\\n}\\n\\n// OAuthTokenCreate stores an access token.\\nfunc OAuthTokenCreate(db *DB, t *OAuthToken) error {\\n\\trevoked := 0\\n\\tif t.Revoked {\\n\\t\\trevoked = 1\\n\\t}\\n\\t_, err := db.Conn.Exec(\\n\\t\\t`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,\\n\\t\\tt.Token, t.ClientID, t.UserID, t.Scope, t.ExpiresAt, revoked, t.CreatedAt,\\n\\t)\\n\\treturn err\\n}\\n\\n// OAuthTokenValidate returns a token if it's valid (not expired, not revoked).\\nfunc OAuthTokenValidate(db *DB, tokenStr string) (*OAuthToken, error) {\\n\\trow := db.Conn.QueryRow(\\n\\t\\t`SELECT token, client_id, user_id, scope, expires_at, revoked, created_at FROM oauth_tokens WHERE token = ?`,\\n\\t\\ttokenStr,\\n\\t)\\n\\tvar t OAuthToken\\n\\tvar revoked int\\n\\tif err := row.Scan(\\u0026t.Token, \\u0026t.ClientID, \\u0026t.UserID, \\u0026t.Scope, \\u0026t.ExpiresAt, \\u0026revoked, \\u0026t.CreatedAt); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tt.Revoked = revoked == 1\\n\\tif t.Revoked || t.ExpiresAt \\u003c time.Now().UnixMilli() {\\n\\t\\treturn nil, ErrAccessDenied\\n\\t}\\n\\treturn \\u0026t, nil\\n}\\n\\n// OAuthTokenRevoke marks a token as revoked.\\nfunc OAuthTokenRevoke(db *DB, tokenStr string) error {\\n\\t_, err := db.Conn.Exec(`UPDATE oauth_tokens SET revoked = 1 WHERE token = ?`, tokenStr)\\n\\treturn err\\n}\\n\\n// SetSessionTestRole updates the test_role on a session (super-admin only).\\n// ListTemplates returns all global request templates.\\nfunc ListTemplates(db *DB, cfg *Config) ([]Entry, error) {\\n\\tfilter := EntryFilter{\\n\\t\\tProjectID: TemplateProjectID,\\n\\t\\tType:      TypeRequestTemplate,\\n\\t}\\n\\tentries, err := entryQuery(db, filter)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\t// Use system key for template project\\n\\ttemplateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tfor i := range entries {\\n\\t\\tif err := unpackEntryWithKey(templateKey, \\u0026entries[i]); err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\t}\\n\\treturn entries, nil\\n}\\n\\n// TemplateByID returns a single request template by entry_id.\\nfunc TemplateByID(db *DB, cfg *Config, entryID string) (*Entry, error) {\\n\\te, err := entryReadSystem(db, entryID)\\n\\tif err != nil || e == nil {\\n\\t\\treturn e, err\\n\\t}\\n\\ttemplateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tif err := unpackEntryWithKey(templateKey, e); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\treturn e, nil\\n}\\n\\nfunc SetSessionTestRole(db *DB, sessionID, role string) error {\\n\\t_, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID)\\n\\treturn err\\n}\"}","is_error":false}},{"type":"tool_result","data":{"tool_call_id":"functions.view:1","name":"","content":"\u003cfile\u003e\n     1|package lib\n     2|\n     3|import \"database/sql\"\n     4|\n     5|// Entry is the single core data type. One table to rule them all.\n     6|type Entry struct {\n     7|\tEntryID    string `json:\"entry_id\"`\n     8|\tProjectID  string `json:\"project_id\"`\n     9|\tParentID   string `json:\"parent_id\"`\n    10|\tType       string `json:\"type\"`\n    11|\tDepth      int    `json:\"depth\"`\n    12|\tSortOrder  int    `json:\"sort_order\"`\n    13|\tSearchKey  []byte `json:\"-\"` // blind index (HMAC-SHA256)\n    14|\tSearchKey2 []byte `json:\"-\"` // blind index (HMAC-SHA256)\n    15|\tSummary    []byte `json:\"-\"` // packed: zstd + AES-256-GCM\n    16|\tData       []byte `json:\"-\"` // packed: zstd + AES-256-GCM\n    17|\tStage      string `json:\"stage\"`\n    18|\n    19|\t// Workflow routing (plain, indexed)\n    20|\tAssigneeID string `json:\"assignee_id\"`\n    21|\tReturnToID string `json:\"return_to_id\"`\n    22|\tOriginID   string `json:\"origin_id\"`\n    23|\n    24|\t// Optimistic locking\n    25|\tVersion int `json:\"version\"`\n    26|\n    27|\t// Soft delete\n    28|\tDeletedAt *int64  `json:\"deleted_at,omitempty\"`\n    29|\tDeletedBy *string `json:\"deleted_by,omitempty\"`\n    30|\n    31|\t// Key rotation\n    32|\tKeyVersion int `json:\"key_version\"`\n    33|\n    34|\t// Timestamps\n    35|\tCreatedAt int64  `json:\"created_at\"`\n    36|\tUpdatedAt int64  `json:\"updated_at\"`\n    37|\tCreatedBy string `json:\"created_by\"`\n    38|\n    39|\t// Unpacked fields (not stored directly, populated by lib)\n    40|\tSummaryText string `json:\"summary,omitempty\"`\n    41|\tDataText    string `json:\"data_text,omitempty\"`\n    42|}\n    43|\n    44|// Entry types\n    45|const (\n    46|\tTypeProject         = \"project\"\n    47|\tTypeWorkstream      = \"workstream\"\n    48|\tTypeRequestList     = \"request_list\"\n    49|\tTypeRequest         = \"request\"\n    50|\tTypeAnswer          = \"answer\"\n    51|\tTypeSection         = \"section\"\n    52|\tTypeComment         = \"comment\"\n    53|\tTypeOrganization    = \"organization\"\n    54|\tTypeDealOrg         = \"deal_org\"\n    55|\tTypeRequestTemplate = \"request_template\" // global template stored under TemplateProjectID\n    56|)\n    57|\n    58|// Stages\n    59|const (\n    60|\tStagePreDataroom = \"pre_dataroom\"\n    61|\tStageDataroom    = \"dataroom\"\n    62|\tStageClosed      = \"closed\"\n    63|)\n    64|\n    65|// OrgData is the JSON structure packed into an organization entry's Data field.\n    66|type OrgData struct {\n    67|\tName         string          `json:\"name\"`\n    68|\tDomains      []string        `json:\"domains\"`                 // required, e.g. [\"kaseya.com\",\"datto.com\"]\n    69|\tRole         string          `json:\"role\"`                    // seller | buyer | ib | advisor\n    70|\tLogo         string          `json:\"logo,omitempty\"`\n    71|\tWebsite      string          `json:\"website,omitempty\"`\n    72|\tDescription  string          `json:\"description,omitempty\"`\n    73|\tIndustry     string          `json:\"industry,omitempty\"`\n    74|\tPhone        string          `json:\"phone,omitempty\"`\n    75|\tFax          string          `json:\"fax,omitempty\"`\n    76|\tAddress      string          `json:\"address,omitempty\"`\n    77|\tCity         string          `json:\"city,omitempty\"`\n    78|\tState        string          `json:\"state,omitempty\"`\n    79|\tCountry      string          `json:\"country,omitempty\"`\n    80|\tFounded      string          `json:\"founded,omitempty\"`\n    81|\tLinkedIn     string          `json:\"linkedin,omitempty\"`\n    82|\tContactName  string          `json:\"contact_name,omitempty\"`\n    83|\tContactEmail string          `json:\"contact_email,omitempty\"`\n    84|\tMembers      []DealOrgMember `json:\"members,omitempty\"`       // global contact registry for this org\n    85|}\n    86|\n    87|// DealOrgData is the JSON structure packed into a deal_org entry's Data field.\n    88|// A deal_org entry links an organization into a specific deal (project).\n    89|type DealOrgData struct {\n    90|\tOrgID      string          `json:\"org_id\"`      // entry_id of the organization\n    91|\tRole       string          `json:\"role\"`        // seller | buyer | ib | advisor\n    92|\tDomainLock bool            `json:\"domain_lock\"` // if true, enforce domain check on invites\n    93|\tPermissions *DealOrgPerms  `json:\"permissions,omitempty\"`\n    94|\tMembers     []DealOrgMember `json:\"members,omitempty\"`\n    95|}\n    96|\n    97|// DealOrgPerms defines what an org can do in a deal.\n    98|type DealOrgPerms struct {\n    99|\tDownload         string      `json:\"download\"`           // \"full\" | \"watermark\" | \"none\"\n   100|\tUpload           bool        `json:\"upload\"`\n   101|\tAddRequestLists  bool        `json:\"add_request_lists\"`\n   102|\tEditRequestLists bool        `json:\"edit_request_lists\"`\n   103|\tFolderAccess     string      `json:\"folder_access\"`      // \"all\" | \"assigned\"\n   104|\tVisibleLists     interface{} `json:\"visible_lists,omitempty\"` // \"all\" | []string of entry_ids\n   105|}\n   106|\n   107|// DealOrgMember is a person associated with a deal org.\n   108|type DealOrgMember struct {\n   109|\tName          string `json:\"name\"`\n   110|\tEmail         string `json:\"email\"`\n   111|\tTitle         string `json:\"title,omitempty\"`\n   112|\tPhone         string `json:\"phone,omitempty\"`          // legacy / kept for compat\n   113|\tPhoneBusiness string `json:\"phone_business,omitempty\"`\n   114|\tPhonePersonal string `json:\"phone_personal,omitempty\"`\n   115|\tPhoto         string `json:\"photo,omitempty\"`\n   116|\tBio           string `json:\"bio,omitempty\"`\n   117|\tLinkedIn      string `json:\"linkedin,omitempty\"`\n   118|}\n   119|\n   120|// User represents an account.\n   121|type User struct {\n   122|\tUserID    string `json:\"user_id\"`\n   123|\tEmail     string `json:\"email\"`\n   124|\tName      string `json:\"name\"`\n   125|\tPassword  string `json:\"-\"` // bcrypt\n   126|\tOrgID     string `json:\"org_id\"`\n   127|\tOrgName   string `json:\"org_name\"`\n   128|\tMFASecret string `json:\"-\"`\n   129|\tActive    bool   `json:\"active\"`\n   130|\tCreatedAt int64  `json:\"created_at\"`\n   131|\tUpdatedAt int64  `json:\"updated_at\"`\n   132|}\n   133|\n   134|// Access is an RBAC grant.\n   135|type Access struct {\n   136|\tID           string `json:\"id\"`\n   137|\tProjectID    string `json:\"project_id\"`\n   138|\tWorkstreamID string `json:\"workstream_id,omitempty\"`\n   139|\tUserID       string `json:\"user_id\"`\n   140|\tRole         string `json:\"role\"`\n   141|\tOps          string `json:\"ops\"`\n   142|\tCanGrant     bool   `json:\"can_grant\"`\n   143|\tGrantedBy    string `json:\"granted_by\"`\n   144|\tGrantedAt    int64  `json:\"granted_at\"`\n   145|\tRevokedAt    *int64 `json:\"revoked_at,omitempty\"`\n   146|\tRevokedBy    string `json:\"revoked_by,omitempty\"`\n   147|}\n   148|\n   149|// Roles\n   150|const (\n   151|\tRoleSuperAdmin   = \"super_admin\"\n   152|\tRoleIBAdmin      = \"ib_admin\"\n   153|\tRoleIBMember     = \"ib_member\"\n   154|\tRoleSellerAdmin  = \"seller_admin\"\n   155|\tRoleSellerMember = \"seller_member\"\n   156|\tRoleBuyerAdmin   = \"buyer_admin\"\n   157|\tRoleBuyerMember  = \"buyer_member\"\n   158|\tRoleObserver     = \"observer\"\n   159|)\n   160|\n   161|// RoleHierarchy defines privilege levels. Higher = more privileged.\n   162|var RoleHierarchy = map[string]int{\n   163|\tRoleSuperAdmin:   200,\n   164|\tRoleIBAdmin:      100,\n   165|\tRoleIBMember:     80,\n   166|\tRoleSellerAdmin:  70,\n   167|\tRoleSellerMember: 50,\n   168|\tRoleBuyerAdmin:   40,\n   169|\tRoleBuyerMember:  30,\n   170|\tRoleObserver:     10,\n   171|}\n   172|\n   173|// AnswerLink connects answers to requests (many-to-many).\n   174|type AnswerLink struct {\n   175|\tAnswerID     string   `json:\"answer_id\"`\n   176|\tRequestID    string   `json:\"request_id\"`\n   177|\tLinkedBy     string   `json:\"linked_by\"`\n   178|\tLinkedAt     int64    `json:\"linked_at\"`\n   179|\tConfirmed    bool     `json:\"confirmed\"`\n   180|\tAIScore      *float64 `json:\"ai_score,omitempty\"`\n   181|\tStatus       string   `json:\"status\"`\n   182|\tReviewedBy   string   `json:\"reviewed_by,omitempty\"`\n   183|\tReviewedAt   *int64   `json:\"reviewed_at,omitempty\"`\n   184|\tRejectReason string   `json:\"reject_reason,omitempty\"`\n   185|}\n   186|\n   187|// EntryEvent is a workflow thread item.\n   188|type EntryEvent struct {\n   189|\tID      string `json:\"id\"`\n   190|\tEntryID string `json:\"entry_id\"`\n   191|\tActorID string `json:\"actor_id\"`\n   192|\tChannel string `json:\"channel\"`\n   193|\tAction  []byte `json:\"-\"` // packed\n   194|\tData    []byte `json:\"-\"` // packed\n   195|\tTs      int64  `json:\"ts\"`\n   196|\n   197|\tActionText string `json:\"action,omitempty\"`\n   198|\tDataText   string `json:\"data_text,omitempty\"`\n   199|}\n   200|\n   201|// AuditEntry is a security audit log row.\n   202|type AuditEntry struct {\n   203|\tID        string `json:\"id\"`\n   204|\tProjectID string `json:\"project_id\"`\n   205|\tActorID   string `json:\"actor_id\"`\n   206|\tAction    []byte `json:\"-\"`\n   207|\tTargetID  string `json:\"target_id,omitempty\"`\n   208|\tDetails   []byte `json:\"-\"`\n   209|\tIP        string `json:\"ip,omitempty\"`\n   210|\tTs        int64  `json:\"ts\"`\n   211|\n   212|\tActionText  string `json:\"action,omitempty\"`\n   213|\tDetailsText string `json:\"details,omitempty\"`\n   214|}\n   215|\n   216|// Session tracks active user sessions.\n   217|type Session struct {\n   218|\tID          string `json:\"id\"`\n   219|\tUserID      string `json:\"user_id\"`\n   220|\tFingerprint string `json:\"fingerprint\"`\n   221|\tCreatedAt   int64  `json:\"created_at\"`\n   222|\tExpiresAt   int64  `json:\"expires_at\"`\n   223|\tRevoked     bool   `json:\"revoked\"`\n   224|\tTestRole    string `json:\"test_role,omitempty\"` // super-admin test impersonation\n   225|}\n   226|\n   227|// Theme is a CSS custom properties bundle.\n   228|type Theme struct {\n   229|\tID         string `json:\"id\"`\n   230|\tName       string `json:\"name\"`\n   231|\tProjectID  string `json:\"project_id,omitempty\"`\n   232|\tProperties string `json:\"properties\"` // packed CSS vars as JSON\n   233|}\n   234|\n   235|// EntryFilter is used by EntryRead to query entries.\n   236|type EntryFilter struct {\n   237|\tProjectID  string\n   238|\tParentID   *string\n   239|\tType       string\n   240|\tStage      string\n   241|\tAssigneeID string\n   242|\tSearchKey  []byte // blind index to match\n   243|\tLimit      int\n   244|\tOffset     int\n   245|}\n   246|\n   247|// DB wraps the database connection.\n   248|type DB struct {\n   249|\tConn *sql.DB\n   250|}\n   251|\n   252|// Challenge represents an email OTP challenge for passwordless login.\n   253|type Challenge struct {\n   254|\tChallengeID string `json:\"challenge_id\"`\n   255|\tEmail       string `json:\"email\"`\n   256|\tCode        string `json:\"code\"`\n   257|\tCreatedAt   int64  `json:\"created_at\"`\n   258|\tExpiresAt   int64  `json:\"expires_at\"`\n   259|\tUsed        bool   `json:\"used\"`\n   260|}\n   261|\n   262|// Config holds application configuration.\n   263|type Config struct {\n   264|\tMasterKey      []byte\n   265|\tDBPath         string\n   266|\tStorePath      string\n   267|\tPort           string\n   268|\tEnv            string // \"development\" | \"production\"\n   269|\tJWTSecret      []byte\n   270|\tMailer         *Mailer\n   271|\tBackdoorCode   string // OTP backdoor for dev/testing\n   272|\tOpenRouterKey  string // OpenRouter API key for LLM features\n   273|}\n   274|\n   275|// RequestData is the JSON structure packed into a request entry's Data field.\n   276|// Represents a diligence request item imported from CSV/XLSX.\n   277|type RequestData struct {\n   278|\tTitle          string   `json:\"title\"`                      // human-readable description\n   279|\tItemNumber     string   `json:\"item_number\"`                // e.g. \"1.3\", \"A-12\"\n   280|\tSection        string   `json:\"section\"`                    // e.g. \"Financial\", \"Legal\"\n   281|\tDescription    string   `json:\"description\"`                // full detail / context\n   282|\tPriority       string   `json:\"priority\"`                   // critical | high | medium | low\n   283|\tStatus         string   `json:\"status\"`                     // open | in_process | partial | complete\n   284|\tAssigneeID     string   `json:\"assignee_id,omitempty\"`\n   285|\tAssigneeName   string   `json:\"assignee_name,omitempty\"`\n   286|\tReviewerID     string   `json:\"reviewer_id,omitempty\"`\n   287|\tDueDate        string   `json:\"due_date,omitempty\"`         // YYYY-MM-DD\n   288|\tCompletedAt    string   `json:\"completed_at,omitempty\"`     // YYYY-MM-DD\n   289|\tBuyerComment   string   `json:\"buyer_comment,omitempty\"`\n   290|\tSellerComment  string   `json:\"seller_comment,omitempty\"`\n   291|\tLabel          string   `json:\"label,omitempty\"`\n   292|\tTags           []string `json:\"tags,omitempty\"`\n   293|\tLinkedEntryIDs []string `json:\"linked_entry_ids,omitempty\"` // linked answer/file entries\n   294|}\n   295|\n   296|// SectionData is the JSON structure packed into a section entry's Data field.\n   297|type SectionData struct {\n   298|\tName string `json:\"name\"`\n   299|}\n   300|\n   301|// RequestListData is the JSON structure packed into a request_list entry's Data field.\n   302|type RequestListData struct {\n   303|\tName            string `json:\"name\"`\n   304|\tOrgID           string `json:\"org_id,omitempty\"`            // PE firm org entry_id\n   305|\tVisibilityOrgID string `json:\"visibility_org_id,omitempty\"` // restrict visibility to this org (+ IB)\n   306|}\n   307|\n   308|// RequestTemplateData is the JSON structure packed into a request_template entry's Data field.\n   309|type RequestTemplateData struct {\n   310|\tName        string              `json:\"name\"`\n   311|\tDescription string              `json:\"description,omitempty\"`\n   312|\tItems       []RequestTemplateItem `json:\"items\"`\n   313|}\n   314|\n   315|// RequestTemplateItem is a single item in a request template.\n   316|type RequestTemplateItem struct {\n   317|\tSection    string `json:\"section\"`\n   318|\tItemNumber string `json:\"item_number,omitempty\"`\n   319|\tTitle      string `json:\"title\"`\n   320|\tPriority   string `json:\"priority,omitempty\"`\n   321|}\n   322|\n   323|// WorkstreamData is the JSON structure packed into a workstream entry's Data field.\n   324|type WorkstreamData struct {\n   325|\tName        string `json:\"name\"`\n   326|\tDescription string `json:\"description,omitempty\"`\n   327|}\n   328|\n   329|// OAuthClient represents a registered OAuth 2.0 client application.\n   330|type OAuthClient struct {\n   331|\tClientID     string   `json:\"client_id\"`\n   332|\tClientName   string   `json:\"client_name\"`\n   333|\tRedirectURIs []string `json:\"redirect_uris\"`\n   334|\tCreatedAt    int64    `json:\"created_at\"`\n   335|}\n   336|\n   337|// OAuthCode represents an OAuth 2.0 authorization code.\n   338|type OAuthCode struct {\n   339|\tCode          string `json:\"code\"`\n   340|\tClientID      string `json:\"client_id\"`\n   341|\tUserID        string `json:\"user_id\"`\n   342|\tRedirectURI   string `json:\"redirect_uri\"`\n   343|\tCodeChallenge string `json:\"code_challenge\"`\n   344|\tScope         string `json:\"scope\"`\n   345|\tExpiresAt     int64  `json:\"expires_at\"`\n   346|\tUsed          bool   `json:\"used\"`\n   347|}\n   348|\n   349|// OAuthToken represents an OAuth 2.0 access token.\n   350|type OAuthToken struct {\n   351|\tToken     string `json:\"token\"`\n   352|\tClientID  string `json:\"client_id\"`\n   353|\tUserID    string `json:\"user_id\"`\n   354|\tScope     string `json:\"scope\"`\n   355|\tExpiresAt int64  `json:\"expires_at\"`\n   356|\tRevoked   bool   `json:\"revoked\"`\n   357|\tCreatedAt int64  `json:\"created_at\"`\n   358|}\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/lib/types.go\",\"content\":\"package lib\\n\\nimport \\\"database/sql\\\"\\n\\n// Entry is the single core data type. One table to rule them all.\\ntype Entry struct {\\n\\tEntryID    string `json:\\\"entry_id\\\"`\\n\\tProjectID  string `json:\\\"project_id\\\"`\\n\\tParentID   string `json:\\\"parent_id\\\"`\\n\\tType       string `json:\\\"type\\\"`\\n\\tDepth      int    `json:\\\"depth\\\"`\\n\\tSortOrder  int    `json:\\\"sort_order\\\"`\\n\\tSearchKey  []byte `json:\\\"-\\\"` // blind index (HMAC-SHA256)\\n\\tSearchKey2 []byte `json:\\\"-\\\"` // blind index (HMAC-SHA256)\\n\\tSummary    []byte `json:\\\"-\\\"` // packed: zstd + AES-256-GCM\\n\\tData       []byte `json:\\\"-\\\"` // packed: zstd + AES-256-GCM\\n\\tStage      string `json:\\\"stage\\\"`\\n\\n\\t// Workflow routing (plain, indexed)\\n\\tAssigneeID string `json:\\\"assignee_id\\\"`\\n\\tReturnToID string `json:\\\"return_to_id\\\"`\\n\\tOriginID   string `json:\\\"origin_id\\\"`\\n\\n\\t// Optimistic locking\\n\\tVersion int `json:\\\"version\\\"`\\n\\n\\t// Soft delete\\n\\tDeletedAt *int64  `json:\\\"deleted_at,omitempty\\\"`\\n\\tDeletedBy *string `json:\\\"deleted_by,omitempty\\\"`\\n\\n\\t// Key rotation\\n\\tKeyVersion int `json:\\\"key_version\\\"`\\n\\n\\t// Timestamps\\n\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n\\tUpdatedAt int64  `json:\\\"updated_at\\\"`\\n\\tCreatedBy string `json:\\\"created_by\\\"`\\n\\n\\t// Unpacked fields (not stored directly, populated by lib)\\n\\tSummaryText string `json:\\\"summary,omitempty\\\"`\\n\\tDataText    string `json:\\\"data_text,omitempty\\\"`\\n}\\n\\n// Entry types\\nconst (\\n\\tTypeProject         = \\\"project\\\"\\n\\tTypeWorkstream      = \\\"workstream\\\"\\n\\tTypeRequestList     = \\\"request_list\\\"\\n\\tTypeRequest         = \\\"request\\\"\\n\\tTypeAnswer          = \\\"answer\\\"\\n\\tTypeSection         = \\\"section\\\"\\n\\tTypeComment         = \\\"comment\\\"\\n\\tTypeOrganization    = \\\"organization\\\"\\n\\tTypeDealOrg         = \\\"deal_org\\\"\\n\\tTypeRequestTemplate = \\\"request_template\\\" // global template stored under TemplateProjectID\\n)\\n\\n// Stages\\nconst (\\n\\tStagePreDataroom = \\\"pre_dataroom\\\"\\n\\tStageDataroom    = \\\"dataroom\\\"\\n\\tStageClosed      = \\\"closed\\\"\\n)\\n\\n// OrgData is the JSON structure packed into an organization entry's Data field.\\ntype OrgData struct {\\n\\tName         string          `json:\\\"name\\\"`\\n\\tDomains      []string        `json:\\\"domains\\\"`                 // required, e.g. [\\\"kaseya.com\\\",\\\"datto.com\\\"]\\n\\tRole         string          `json:\\\"role\\\"`                    // seller | buyer | ib | advisor\\n\\tLogo         string          `json:\\\"logo,omitempty\\\"`\\n\\tWebsite      string          `json:\\\"website,omitempty\\\"`\\n\\tDescription  string          `json:\\\"description,omitempty\\\"`\\n\\tIndustry     string          `json:\\\"industry,omitempty\\\"`\\n\\tPhone        string          `json:\\\"phone,omitempty\\\"`\\n\\tFax          string          `json:\\\"fax,omitempty\\\"`\\n\\tAddress      string          `json:\\\"address,omitempty\\\"`\\n\\tCity         string          `json:\\\"city,omitempty\\\"`\\n\\tState        string          `json:\\\"state,omitempty\\\"`\\n\\tCountry      string          `json:\\\"country,omitempty\\\"`\\n\\tFounded      string          `json:\\\"founded,omitempty\\\"`\\n\\tLinkedIn     string          `json:\\\"linkedin,omitempty\\\"`\\n\\tContactName  string          `json:\\\"contact_name,omitempty\\\"`\\n\\tContactEmail string          `json:\\\"contact_email,omitempty\\\"`\\n\\tMembers      []DealOrgMember `json:\\\"members,omitempty\\\"`       // global contact registry for this org\\n}\\n\\n// DealOrgData is the JSON structure packed into a deal_org entry's Data field.\\n// A deal_org entry links an organization into a specific deal (project).\\ntype DealOrgData struct {\\n\\tOrgID      string          `json:\\\"org_id\\\"`      // entry_id of the organization\\n\\tRole       string          `json:\\\"role\\\"`        // seller | buyer | ib | advisor\\n\\tDomainLock bool            `json:\\\"domain_lock\\\"` // if true, enforce domain check on invites\\n\\tPermissions *DealOrgPerms  `json:\\\"permissions,omitempty\\\"`\\n\\tMembers     []DealOrgMember `json:\\\"members,omitempty\\\"`\\n}\\n\\n// DealOrgPerms defines what an org can do in a deal.\\ntype DealOrgPerms struct {\\n\\tDownload         string      `json:\\\"download\\\"`           // \\\"full\\\" | \\\"watermark\\\" | \\\"none\\\"\\n\\tUpload           bool        `json:\\\"upload\\\"`\\n\\tAddRequestLists  bool        `json:\\\"add_request_lists\\\"`\\n\\tEditRequestLists bool        `json:\\\"edit_request_lists\\\"`\\n\\tFolderAccess     string      `json:\\\"folder_access\\\"`      // \\\"all\\\" | \\\"assigned\\\"\\n\\tVisibleLists     interface{} `json:\\\"visible_lists,omitempty\\\"` // \\\"all\\\" | []string of entry_ids\\n}\\n\\n// DealOrgMember is a person associated with a deal org.\\ntype DealOrgMember struct {\\n\\tName          string `json:\\\"name\\\"`\\n\\tEmail         string `json:\\\"email\\\"`\\n\\tTitle         string `json:\\\"title,omitempty\\\"`\\n\\tPhone         string `json:\\\"phone,omitempty\\\"`          // legacy / kept for compat\\n\\tPhoneBusiness string `json:\\\"phone_business,omitempty\\\"`\\n\\tPhonePersonal string `json:\\\"phone_personal,omitempty\\\"`\\n\\tPhoto         string `json:\\\"photo,omitempty\\\"`\\n\\tBio           string `json:\\\"bio,omitempty\\\"`\\n\\tLinkedIn      string `json:\\\"linkedin,omitempty\\\"`\\n}\\n\\n// User represents an account.\\ntype User struct {\\n\\tUserID    string `json:\\\"user_id\\\"`\\n\\tEmail     string `json:\\\"email\\\"`\\n\\tName      string `json:\\\"name\\\"`\\n\\tPassword  string `json:\\\"-\\\"` // bcrypt\\n\\tOrgID     string `json:\\\"org_id\\\"`\\n\\tOrgName   string `json:\\\"org_name\\\"`\\n\\tMFASecret string `json:\\\"-\\\"`\\n\\tActive    bool   `json:\\\"active\\\"`\\n\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n\\tUpdatedAt int64  `json:\\\"updated_at\\\"`\\n}\\n\\n// Access is an RBAC grant.\\ntype Access struct {\\n\\tID           string `json:\\\"id\\\"`\\n\\tProjectID    string `json:\\\"project_id\\\"`\\n\\tWorkstreamID string `json:\\\"workstream_id,omitempty\\\"`\\n\\tUserID       string `json:\\\"user_id\\\"`\\n\\tRole         string `json:\\\"role\\\"`\\n\\tOps          string `json:\\\"ops\\\"`\\n\\tCanGrant     bool   `json:\\\"can_grant\\\"`\\n\\tGrantedBy    string `json:\\\"granted_by\\\"`\\n\\tGrantedAt    int64  `json:\\\"granted_at\\\"`\\n\\tRevokedAt    *int64 `json:\\\"revoked_at,omitempty\\\"`\\n\\tRevokedBy    string `json:\\\"revoked_by,omitempty\\\"`\\n}\\n\\n// Roles\\nconst (\\n\\tRoleSuperAdmin   = \\\"super_admin\\\"\\n\\tRoleIBAdmin      = \\\"ib_admin\\\"\\n\\tRoleIBMember     = \\\"ib_member\\\"\\n\\tRoleSellerAdmin  = \\\"seller_admin\\\"\\n\\tRoleSellerMember = \\\"seller_member\\\"\\n\\tRoleBuyerAdmin   = \\\"buyer_admin\\\"\\n\\tRoleBuyerMember  = \\\"buyer_member\\\"\\n\\tRoleObserver     = \\\"observer\\\"\\n)\\n\\n// RoleHierarchy defines privilege levels. Higher = more privileged.\\nvar RoleHierarchy = map[string]int{\\n\\tRoleSuperAdmin:   200,\\n\\tRoleIBAdmin:      100,\\n\\tRoleIBMember:     80,\\n\\tRoleSellerAdmin:  70,\\n\\tRoleSellerMember: 50,\\n\\tRoleBuyerAdmin:   40,\\n\\tRoleBuyerMember:  30,\\n\\tRoleObserver:     10,\\n}\\n\\n// AnswerLink connects answers to requests (many-to-many).\\ntype AnswerLink struct {\\n\\tAnswerID     string   `json:\\\"answer_id\\\"`\\n\\tRequestID    string   `json:\\\"request_id\\\"`\\n\\tLinkedBy     string   `json:\\\"linked_by\\\"`\\n\\tLinkedAt     int64    `json:\\\"linked_at\\\"`\\n\\tConfirmed    bool     `json:\\\"confirmed\\\"`\\n\\tAIScore      *float64 `json:\\\"ai_score,omitempty\\\"`\\n\\tStatus       string   `json:\\\"status\\\"`\\n\\tReviewedBy   string   `json:\\\"reviewed_by,omitempty\\\"`\\n\\tReviewedAt   *int64   `json:\\\"reviewed_at,omitempty\\\"`\\n\\tRejectReason string   `json:\\\"reject_reason,omitempty\\\"`\\n}\\n\\n// EntryEvent is a workflow thread item.\\ntype EntryEvent struct {\\n\\tID      string `json:\\\"id\\\"`\\n\\tEntryID string `json:\\\"entry_id\\\"`\\n\\tActorID string `json:\\\"actor_id\\\"`\\n\\tChannel string `json:\\\"channel\\\"`\\n\\tAction  []byte `json:\\\"-\\\"` // packed\\n\\tData    []byte `json:\\\"-\\\"` // packed\\n\\tTs      int64  `json:\\\"ts\\\"`\\n\\n\\tActionText string `json:\\\"action,omitempty\\\"`\\n\\tDataText   string `json:\\\"data_text,omitempty\\\"`\\n}\\n\\n// AuditEntry is a security audit log row.\\ntype AuditEntry struct {\\n\\tID        string `json:\\\"id\\\"`\\n\\tProjectID string `json:\\\"project_id\\\"`\\n\\tActorID   string `json:\\\"actor_id\\\"`\\n\\tAction    []byte `json:\\\"-\\\"`\\n\\tTargetID  string `json:\\\"target_id,omitempty\\\"`\\n\\tDetails   []byte `json:\\\"-\\\"`\\n\\tIP        string `json:\\\"ip,omitempty\\\"`\\n\\tTs        int64  `json:\\\"ts\\\"`\\n\\n\\tActionText  string `json:\\\"action,omitempty\\\"`\\n\\tDetailsText string `json:\\\"details,omitempty\\\"`\\n}\\n\\n// Session tracks active user sessions.\\ntype Session struct {\\n\\tID          string `json:\\\"id\\\"`\\n\\tUserID      string `json:\\\"user_id\\\"`\\n\\tFingerprint string `json:\\\"fingerprint\\\"`\\n\\tCreatedAt   int64  `json:\\\"created_at\\\"`\\n\\tExpiresAt   int64  `json:\\\"expires_at\\\"`\\n\\tRevoked     bool   `json:\\\"revoked\\\"`\\n\\tTestRole    string `json:\\\"test_role,omitempty\\\"` // super-admin test impersonation\\n}\\n\\n// Theme is a CSS custom properties bundle.\\ntype Theme struct {\\n\\tID         string `json:\\\"id\\\"`\\n\\tName       string `json:\\\"name\\\"`\\n\\tProjectID  string `json:\\\"project_id,omitempty\\\"`\\n\\tProperties string `json:\\\"properties\\\"` // packed CSS vars as JSON\\n}\\n\\n// EntryFilter is used by EntryRead to query entries.\\ntype EntryFilter struct {\\n\\tProjectID  string\\n\\tParentID   *string\\n\\tType       string\\n\\tStage      string\\n\\tAssigneeID string\\n\\tSearchKey  []byte // blind index to match\\n\\tLimit      int\\n\\tOffset     int\\n}\\n\\n// DB wraps the database connection.\\ntype DB struct {\\n\\tConn *sql.DB\\n}\\n\\n// Challenge represents an email OTP challenge for passwordless login.\\ntype Challenge struct {\\n\\tChallengeID string `json:\\\"challenge_id\\\"`\\n\\tEmail       string `json:\\\"email\\\"`\\n\\tCode        string `json:\\\"code\\\"`\\n\\tCreatedAt   int64  `json:\\\"created_at\\\"`\\n\\tExpiresAt   int64  `json:\\\"expires_at\\\"`\\n\\tUsed        bool   `json:\\\"used\\\"`\\n}\\n\\n// Config holds application configuration.\\ntype Config struct {\\n\\tMasterKey      []byte\\n\\tDBPath         string\\n\\tStorePath      string\\n\\tPort           string\\n\\tEnv            string // \\\"development\\\" | \\\"production\\\"\\n\\tJWTSecret      []byte\\n\\tMailer         *Mailer\\n\\tBackdoorCode   string // OTP backdoor for dev/testing\\n\\tOpenRouterKey  string // OpenRouter API key for LLM features\\n}\\n\\n// RequestData is the JSON structure packed into a request entry's Data field.\\n// Represents a diligence request item imported from CSV/XLSX.\\ntype RequestData struct {\\n\\tTitle          string   `json:\\\"title\\\"`                      // human-readable description\\n\\tItemNumber     string   `json:\\\"item_number\\\"`                // e.g. \\\"1.3\\\", \\\"A-12\\\"\\n\\tSection        string   `json:\\\"section\\\"`                    // e.g. \\\"Financial\\\", \\\"Legal\\\"\\n\\tDescription    string   `json:\\\"description\\\"`                // full detail / context\\n\\tPriority       string   `json:\\\"priority\\\"`                   // critical | high | medium | low\\n\\tStatus         string   `json:\\\"status\\\"`                     // open | in_process | partial | complete\\n\\tAssigneeID     string   `json:\\\"assignee_id,omitempty\\\"`\\n\\tAssigneeName   string   `json:\\\"assignee_name,omitempty\\\"`\\n\\tReviewerID     string   `json:\\\"reviewer_id,omitempty\\\"`\\n\\tDueDate        string   `json:\\\"due_date,omitempty\\\"`         // YYYY-MM-DD\\n\\tCompletedAt    string   `json:\\\"completed_at,omitempty\\\"`     // YYYY-MM-DD\\n\\tBuyerComment   string   `json:\\\"buyer_comment,omitempty\\\"`\\n\\tSellerComment  string   `json:\\\"seller_comment,omitempty\\\"`\\n\\tLabel          string   `json:\\\"label,omitempty\\\"`\\n\\tTags           []string `json:\\\"tags,omitempty\\\"`\\n\\tLinkedEntryIDs []string `json:\\\"linked_entry_ids,omitempty\\\"` // linked answer/file entries\\n}\\n\\n// SectionData is the JSON structure packed into a section entry's Data field.\\ntype SectionData struct {\\n\\tName string `json:\\\"name\\\"`\\n}\\n\\n// RequestListData is the JSON structure packed into a request_list entry's Data field.\\ntype RequestListData struct {\\n\\tName            string `json:\\\"name\\\"`\\n\\tOrgID           string `json:\\\"org_id,omitempty\\\"`            // PE firm org entry_id\\n\\tVisibilityOrgID string `json:\\\"visibility_org_id,omitempty\\\"` // restrict visibility to this org (+ IB)\\n}\\n\\n// RequestTemplateData is the JSON structure packed into a request_template entry's Data field.\\ntype RequestTemplateData struct {\\n\\tName        string              `json:\\\"name\\\"`\\n\\tDescription string              `json:\\\"description,omitempty\\\"`\\n\\tItems       []RequestTemplateItem `json:\\\"items\\\"`\\n}\\n\\n// RequestTemplateItem is a single item in a request template.\\ntype RequestTemplateItem struct {\\n\\tSection    string `json:\\\"section\\\"`\\n\\tItemNumber string `json:\\\"item_number,omitempty\\\"`\\n\\tTitle      string `json:\\\"title\\\"`\\n\\tPriority   string `json:\\\"priority,omitempty\\\"`\\n}\\n\\n// WorkstreamData is the JSON structure packed into a workstream entry's Data field.\\ntype WorkstreamData struct {\\n\\tName        string `json:\\\"name\\\"`\\n\\tDescription string `json:\\\"description,omitempty\\\"`\\n}\\n\\n// OAuthClient represents a registered OAuth 2.0 client application.\\ntype OAuthClient struct {\\n\\tClientID     string   `json:\\\"client_id\\\"`\\n\\tClientName   string   `json:\\\"client_name\\\"`\\n\\tRedirectURIs []string `json:\\\"redirect_uris\\\"`\\n\\tCreatedAt    int64    `json:\\\"created_at\\\"`\\n}\\n\\n// OAuthCode represents an OAuth 2.0 authorization code.\\ntype OAuthCode struct {\\n\\tCode          string `json:\\\"code\\\"`\\n\\tClientID      string `json:\\\"client_id\\\"`\\n\\tUserID        string `json:\\\"user_id\\\"`\\n\\tRedirectURI   string `json:\\\"redirect_uri\\\"`\\n\\tCodeChallenge string `json:\\\"code_challenge\\\"`\\n\\tScope         string `json:\\\"scope\\\"`\\n\\tExpiresAt     int64  `json:\\\"expires_at\\\"`\\n\\tUsed          bool   `json:\\\"used\\\"`\\n}\\n\\n// OAuthToken represents an OAuth 2.0 access token.\\ntype OAuthToken struct {\\n\\tToken     string `json:\\\"token\\\"`\\n\\tClientID  string `json:\\\"client_id\\\"`\\n\\tUserID    string `json:\\\"user_id\\\"`\\n\\tScope     string `json:\\\"scope\\\"`\\n\\tExpiresAt int64  `json:\\\"expires_at\\\"`\\n\\tRevoked   bool   `json:\\\"revoked\\\"`\\n\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n}\"}","is_error":false}},{"type":"tool_result","data":{"tool_call_id":"functions.view:2","name":"","content":"\u003cfile\u003e\n     1|package api\n     2|\n     3|import (\n     4|\t\"context\"\n     5|\t\"io/fs\"\n     6|\t\"net/http\"\n     7|\t\"os\"\n     8|\n     9|\t\"github.com/go-chi/chi/v5\"\n    10|\t\"github.com/mark3labs/mcp-go/server\"\n    11|\t\"github.com/mish/dealspace/lib\"\n    12|)\n    13|\n    14|// NewRouter creates the main router with all routes registered.\n    15|func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.FS, portalFS fs.FS, mcpServer *server.MCPServer) *chi.Mux {\n    16|\tr := chi.NewRouter()\n    17|\th := NewHandlers(db, cfg, store)\n    18|\toauth := NewOAuthHandlers(db, cfg)\n    19|\n    20|\t// Global middleware\n    21|\tr.Use(LoggingMiddleware)\n    22|\tr.Use(CORSMiddleware)\n    23|\tr.Use(SecurityHeadersMiddleware)\n    24|\tr.Use(BlockDatabaseMiddleware) // HARD RULE: no raw DB files served under any circumstance\n    25|\tr.Use(RateLimitMiddleware(120)) // 120 req/min per IP\n    26|\n    27|\t// Health check (unauthenticated)\n    28|\tr.Get(\"/health\", h.Health)\n    29|\n    30|\t// Chat endpoint (unauthenticated, for Aria chatbot)\n    31|\tr.Post(\"/api/chat\", h.ChatHandler)\n    32|\tr.Options(\"/api/chat\", h.ChatHandler)\n    33|\n    34|\t// Auth endpoints (unauthenticated — email challenge OTP flow)\n    35|\tr.Post(\"/api/auth/challenge\", h.Challenge)\n    36|\tr.Post(\"/api/auth/verify\", h.Verify)\n    37|\n    38|\t// Auth endpoints (need token for logout/me)\n    39|\tr.Group(func(r chi.Router) {\n    40|\t\tr.Use(AuthMiddleware(db, cfg.JWTSecret))\n    41|\t\tr.Post(\"/api/auth/logout\", h.Logout)\n    42|\t\tr.Get(\"/api/auth/me\", h.Me)\n    43|\t})\n    44|\n    45|\t// API routes (authenticated)\n    46|\tr.Route(\"/api\", func(r chi.Router) {\n    47|\t\tr.Use(AuthMiddleware(db, cfg.JWTSecret))\n    48|\n    49|\t\t// Tasks (cross-project)\n    50|\t\tr.Get(\"/tasks\", h.GetAllTasks)\n    51|\n    52|\t\t// Projects\n    53|\t\tr.Get(\"/projects\", h.GetAllProjects)\n    54|\t\tr.Post(\"/projects\", h.CreateProject)\n    55|\t\tr.Get(\"/projects/{projectID}\", h.GetProjectDetail)\n    56|\t\tr.Get(\"/projects/{projectID}/detail\", h.GetProjectDetail) // legacy alias\n    57|\t\tr.Delete(\"/projects/{projectID}\", h.DeleteProject)\n    58|\n    59|\t\t// Workstreams\n    60|\t\tr.Post(\"/projects/{projectID}/workstreams\", h.CreateWorkstream)\n    61|\n    62|\t\t// Entries\n    63|\t\tr.Get(\"/projects/{projectID}/entries\", h.ListEntries)\n    64|\t\tr.Post(\"/projects/{projectID}/entries\", h.CreateEntry)\n    65|\t\tr.Put(\"/projects/{projectID}/entries/{entryID}\", h.UpdateEntry)\n    66|\t\tr.Delete(\"/projects/{projectID}/entries/{entryID}\", h.DeleteEntry)\n    67|\n    68|\t\t// Task inbox (per-project)\n    69|\t\tr.Get(\"/projects/{projectID}/tasks\", h.GetMyTasks)\n    70|\n    71|\t\t// Requests (list, tree, and import)\n    72|\t\tr.Get(\"/projects/{projectID}/requests\", h.ListRequests)\n    73|\t\tr.Get(\"/projects/{projectID}/requests/tree\", h.ListRequestTree)\n    74|\t\tr.Post(\"/projects/{projectID}/requests\", h.CreateRequestList)\n    75|\t\tr.Post(\"/projects/{projectID}/requests/new\", h.CreateRequest)\n    76|\t\tr.Post(\"/projects/{projectID}/sections\", h.CreateSection)\n    77|\t\tr.Post(\"/projects/{projectID}/requests/import\", h.ImportRequests)\n    78|\t\tr.Post(\"/projects/{projectID}/requests/import-template\", h.ImportTemplate)\n    79|\t\tr.Get(\"/templates\", h.ListTemplates)\n    80|\t\tr.Post(\"/templates\", h.SaveTemplate)\n    81|\n    82|\t\t// Request detail\n    83|\t\tr.Get(\"/requests/{requestID}\", h.GetRequestDetail)\n    84|\n    85|\t\t// Entry move (drag \u0026 drop)\n    86|\t\tr.Post(\"/projects/{projectID}/entries/{entryID}/move\", h.MoveEntry)\n    87|\n    88|\t\t// Answer links\n    89|\t\tr.Get(\"/projects/{projectID}/requests/{requestID}/links\", h.ListAnswerLinks)\n    90|\t\tr.Post(\"/projects/{projectID}/requests/{requestID}/links\", h.CreateAnswerLink)\n    91|\t\tr.Delete(\"/projects/{projectID}/requests/{requestID}/links/{answerID}\", h.DeleteAnswerLink)\n    92|\n    93|\t\t// Answers (picker)\n    94|\t\tr.Get(\"/projects/{projectID}/answers\", h.ListAnswers)\n    95|\n    96|\t\t// File upload/download/preview\n    97|\t\tr.Post(\"/projects/{projectID}/objects\", h.UploadObject)\n    98|\t\tr.Get(\"/projects/{projectID}/objects/{objectID}\", h.DownloadObject)\n    99|\t\tr.Get(\"/projects/{projectID}/objects/{objectID}/preview\", h.PreviewObject)\n   100|\n   101|\t\t// Request list visibility\n   102|\t\tr.Patch(\"/projects/{projectID}/entries/{entryID}/visibility\", h.UpdateRequestListVisibility)\n   103|\n   104|\t\t// Super admin endpoints\n   105|\t\t// Organizations (platform level)\n   106|\t\tr.Get(\"/orgs\", h.ListOrgs)\n   107|\t\tr.Post(\"/orgs\", h.CreateOrg)\n   108|\t\tr.Get(\"/orgs/{orgID}/deals\", h.OrgDeals)\n   109|\t\tr.Put(\"/orgs/{orgID}/members\", h.UpdateOrgMembers)\n   110|\t\tr.Post(\"/orgs/{orgID}/rescrape\", h.RescrapeOrg)\n   111|\t\tr.Put(\"/orgs/{orgID}\", h.UpdateOrg)\n   112|\t\tr.Delete(\"/orgs/{orgID}\", h.DeleteOrg)\n   113|\t\tr.Put(\"/admin/test-role\", h.SetTestRole)\n   114|\t\tr.Get(\"/orgs/{orgID}\", h.GetOrg)\n   115|\t\tr.Patch(\"/orgs/{orgID}\", h.UpdateOrg)\n   116|\n   117|\t\t// Deal orgs (per project)\n   118|\t\tr.Get(\"/projects/{projectID}/orgs\", h.ListDealOrgs)\n   119|\t\tr.Post(\"/projects/{projectID}/orgs\", h.CreateDealOrg)\n   120|\t\tr.Delete(\"/projects/{projectID}/orgs/{dealOrgID}\", h.DeleteDealOrg)\n   121|\t\tr.Post(\"/projects/{projectID}/orgs/add\", h.AddOrgToDeal)\n   122|\t\tr.Patch(\"/projects/{projectID}/orgs/{dealOrgID}\", h.UpdateDealOrg)\n   123|\n   124|\t\t// Scrape (LLM-powered org lookup)\n   125|\t\tr.Post(\"/scrape/org\", h.ScrapeOrg)\n   126|\n   127|\t\tr.Get(\"/admin/users\", h.AdminListUsers)\n   128|\t\tr.Get(\"/admin/projects\", h.AdminListProjects)\n   129|\t\tr.Get(\"/admin/audit\", h.AdminAuditLog)\n   130|\t\tr.Post(\"/admin/impersonate\", h.AdminImpersonate)\n   131|\t})\n   132|\n   133|\t// OAuth metadata (unauthenticated, auto-discovery)\n   134|\tr.Get(\"/.well-known/oauth-authorization-server\", oauth.Metadata)\n   135|\tr.Get(\"/.well-known/oauth-protected-resource\", oauth.ResourceMetadata)\n   136|\n   137|\t// OAuth endpoints\n   138|\tr.Get(\"/oauth/authorize\", oauth.Authorize)\n   139|\tr.Post(\"/oauth/authorize\", oauth.AuthorizeApprove)\n   140|\tr.Post(\"/oauth/token\", oauth.Token)\n   141|\tr.Post(\"/oauth/revoke\", oauth.Revoke)\n   142|\n   143|\t// MCP endpoint (OAuth bearer auth)\n   144|\tif mcpServer != nil {\n   145|\t\tmcpHTTP := server.NewStreamableHTTPServer(mcpServer,\n   146|\t\t\tserver.WithEndpointPath(\"/mcp\"),\n   147|\t\t\tserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {\n   148|\t\t\t\tif uid := UserIDFromContext(r.Context()); uid != \"\" {\n   149|\t\t\t\t\treturn context.WithValue(ctx, ctxUserID, uid)\n   150|\t\t\t\t}\n   151|\t\t\t\treturn ctx\n   152|\t\t\t}),\n   153|\t\t)\n   154|\t\tr.Group(func(r chi.Router) {\n   155|\t\t\tr.Use(OAuthBearerAuth(db))\n   156|\t\t\tr.Handle(\"/mcp\", mcpHTTP)\n   157|\t\t})\n   158|\t}\n   159|\n   160|\t// Request list templates (JSON)\n   161|\tr.Handle(\"/templates/*\", http.StripPrefix(\"/templates/\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n   162|\t\tdirs := []string{\"templates\", \"/opt/dealspace/templates\"}\n   163|\t\tfor _, dir := range dirs {\n   164|\t\t\tfp := dir + \"/\" + r.URL.Path\n   165|\t\t\tif _, err := os.Stat(fp); err == nil {\n   166|\t\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n   167|\t\t\t\thttp.ServeFile(w, r, fp)\n   168|\t\t\t\treturn\n   169|\t\t\t}\n   170|\t\t}\n   171|\t\thttp.NotFound(w, r)\n   172|\t})))\n   173|\n   174|\t// Static files (portal + website CSS/JS)\n   175|\tr.Handle(\"/static/*\", http.StripPrefix(\"/static/\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n   176|\t\tdirs := []string{\n   177|\t\t\t\"portal/static\", \"/opt/dealspace/portal/static\",\n   178|\t\t\t\"website/static\", \"/opt/dealspace/website/static\",\n   179|\t\t}\n   180|\t\tfor _, dir := range dirs {\n   181|\t\t\tfp := dir + \"/\" + r.URL.Path\n   182|\t\t\tif _, err := os.Stat(fp); err == nil {\n   183|\t\t\t\thttp.FileServer(http.Dir(dir)).ServeHTTP(w, r)\n   184|\t\t\t\treturn\n   185|\t\t\t}\n   186|\t\t}\n   187|\t\thttp.NotFound(w, r)\n   188|\t})))\n   189|\n   190|\t// Portal app routes (serve templates, auth checked client-side via JS)\n   191|\tr.Get(\"/app\", func(w http.ResponseWriter, r *http.Request) {\n   192|\t\thttp.Redirect(w, r, \"/app/projects\", http.StatusFound)\n   193|\t})\n   194|\tr.Get(\"/app/login\", h.ServeLogin)\n   195|\tr.Get(\"/app/tasks\", h.ServeAppTasks)\n   196|\tr.Get(\"/app/projects\", h.ServeAppProjects)\n   197|\tr.Get(\"/app/projects/{id}\", h.ServeAppProject)\n   198|\tr.Get(\"/app/requests/{id}\", h.ServeAppRequest)\n   199|\tr.Get(\"/app/orgs\", h.ServeAppOrgs)\n   200|\n   201|\t// Admin UI (super admin only, auth checked client-side)\n   202|\tr.Get(\"/admin\", h.ServeAdmin)\n   203|\tr.Get(\"/admin/*\", h.ServeAdmin)\n   204|\n   205|\t// Marketing website pages (template-based)\n   206|\tr.Get(\"/\", h.ServeWebsiteIndex)\n   207|\tr.Get(\"/features\", h.ServeWebsiteFeatures)\n   208|\tr.Get(\"/pricing\", h.ServeWebsitePricing)\n   209|\tr.Get(\"/security\", h.ServeWebsiteSecurity)\n   210|\tr.Get(\"/privacy\", h.ServeWebsitePrivacy)\n   211|\tr.Get(\"/terms\", h.ServeWebsiteTerms)\n   212|\tr.Get(\"/dpa\", h.ServeWebsiteDPA)\n   213|\tr.Get(\"/soc2\", h.ServeWebsiteSOC2)\n   214|\n   215|\t// Website static assets (embedded files) — serves at root, must be last\n   216|\tif websiteFS != nil {\n   217|\t\twebsiteHandler := http.FileServerFS(websiteFS)\n   218|\t\tr.Handle(\"/*\", websiteHandler)\n   219|\t}\n   220|\n   221|\treturn r\n   222|}\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/api/routes.go\",\"content\":\"package api\\n\\nimport (\\n\\t\\\"context\\\"\\n\\t\\\"io/fs\\\"\\n\\t\\\"net/http\\\"\\n\\t\\\"os\\\"\\n\\n\\t\\\"github.com/go-chi/chi/v5\\\"\\n\\t\\\"github.com/mark3labs/mcp-go/server\\\"\\n\\t\\\"github.com/mish/dealspace/lib\\\"\\n)\\n\\n// NewRouter creates the main router with all routes registered.\\nfunc NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.FS, portalFS fs.FS, mcpServer *server.MCPServer) *chi.Mux {\\n\\tr := chi.NewRouter()\\n\\th := NewHandlers(db, cfg, store)\\n\\toauth := NewOAuthHandlers(db, cfg)\\n\\n\\t// Global middleware\\n\\tr.Use(LoggingMiddleware)\\n\\tr.Use(CORSMiddleware)\\n\\tr.Use(SecurityHeadersMiddleware)\\n\\tr.Use(BlockDatabaseMiddleware) // HARD RULE: no raw DB files served under any circumstance\\n\\tr.Use(RateLimitMiddleware(120)) // 120 req/min per IP\\n\\n\\t// Health check (unauthenticated)\\n\\tr.Get(\\\"/health\\\", h.Health)\\n\\n\\t// Chat endpoint (unauthenticated, for Aria chatbot)\\n\\tr.Post(\\\"/api/chat\\\", h.ChatHandler)\\n\\tr.Options(\\\"/api/chat\\\", h.ChatHandler)\\n\\n\\t// Auth endpoints (unauthenticated — email challenge OTP flow)\\n\\tr.Post(\\\"/api/auth/challenge\\\", h.Challenge)\\n\\tr.Post(\\\"/api/auth/verify\\\", h.Verify)\\n\\n\\t// Auth endpoints (need token for logout/me)\\n\\tr.Group(func(r chi.Router) {\\n\\t\\tr.Use(AuthMiddleware(db, cfg.JWTSecret))\\n\\t\\tr.Post(\\\"/api/auth/logout\\\", h.Logout)\\n\\t\\tr.Get(\\\"/api/auth/me\\\", h.Me)\\n\\t})\\n\\n\\t// API routes (authenticated)\\n\\tr.Route(\\\"/api\\\", func(r chi.Router) {\\n\\t\\tr.Use(AuthMiddleware(db, cfg.JWTSecret))\\n\\n\\t\\t// Tasks (cross-project)\\n\\t\\tr.Get(\\\"/tasks\\\", h.GetAllTasks)\\n\\n\\t\\t// Projects\\n\\t\\tr.Get(\\\"/projects\\\", h.GetAllProjects)\\n\\t\\tr.Post(\\\"/projects\\\", h.CreateProject)\\n\\t\\tr.Get(\\\"/projects/{projectID}\\\", h.GetProjectDetail)\\n\\t\\tr.Get(\\\"/projects/{projectID}/detail\\\", h.GetProjectDetail) // legacy alias\\n\\t\\tr.Delete(\\\"/projects/{projectID}\\\", h.DeleteProject)\\n\\n\\t\\t// Workstreams\\n\\t\\tr.Post(\\\"/projects/{projectID}/workstreams\\\", h.CreateWorkstream)\\n\\n\\t\\t// Entries\\n\\t\\tr.Get(\\\"/projects/{projectID}/entries\\\", h.ListEntries)\\n\\t\\tr.Post(\\\"/projects/{projectID}/entries\\\", h.CreateEntry)\\n\\t\\tr.Put(\\\"/projects/{projectID}/entries/{entryID}\\\", h.UpdateEntry)\\n\\t\\tr.Delete(\\\"/projects/{projectID}/entries/{entryID}\\\", h.DeleteEntry)\\n\\n\\t\\t// Task inbox (per-project)\\n\\t\\tr.Get(\\\"/projects/{projectID}/tasks\\\", h.GetMyTasks)\\n\\n\\t\\t// Requests (list, tree, and import)\\n\\t\\tr.Get(\\\"/projects/{projectID}/requests\\\", h.ListRequests)\\n\\t\\tr.Get(\\\"/projects/{projectID}/requests/tree\\\", h.ListRequestTree)\\n\\t\\tr.Post(\\\"/projects/{projectID}/requests\\\", h.CreateRequestList)\\n\\t\\tr.Post(\\\"/projects/{projectID}/requests/new\\\", h.CreateRequest)\\n\\t\\tr.Post(\\\"/projects/{projectID}/sections\\\", h.CreateSection)\\n\\t\\tr.Post(\\\"/projects/{projectID}/requests/import\\\", h.ImportRequests)\\n\\t\\tr.Post(\\\"/projects/{projectID}/requests/import-template\\\", h.ImportTemplate)\\n\\t\\tr.Get(\\\"/templates\\\", h.ListTemplates)\\n\\t\\tr.Post(\\\"/templates\\\", h.SaveTemplate)\\n\\n\\t\\t// Request detail\\n\\t\\tr.Get(\\\"/requests/{requestID}\\\", h.GetRequestDetail)\\n\\n\\t\\t// Entry move (drag \\u0026 drop)\\n\\t\\tr.Post(\\\"/projects/{projectID}/entries/{entryID}/move\\\", h.MoveEntry)\\n\\n\\t\\t// Answer links\\n\\t\\tr.Get(\\\"/projects/{projectID}/requests/{requestID}/links\\\", h.ListAnswerLinks)\\n\\t\\tr.Post(\\\"/projects/{projectID}/requests/{requestID}/links\\\", h.CreateAnswerLink)\\n\\t\\tr.Delete(\\\"/projects/{projectID}/requests/{requestID}/links/{answerID}\\\", h.DeleteAnswerLink)\\n\\n\\t\\t// Answers (picker)\\n\\t\\tr.Get(\\\"/projects/{projectID}/answers\\\", h.ListAnswers)\\n\\n\\t\\t// File upload/download/preview\\n\\t\\tr.Post(\\\"/projects/{projectID}/objects\\\", h.UploadObject)\\n\\t\\tr.Get(\\\"/projects/{projectID}/objects/{objectID}\\\", h.DownloadObject)\\n\\t\\tr.Get(\\\"/projects/{projectID}/objects/{objectID}/preview\\\", h.PreviewObject)\\n\\n\\t\\t// Request list visibility\\n\\t\\tr.Patch(\\\"/projects/{projectID}/entries/{entryID}/visibility\\\", h.UpdateRequestListVisibility)\\n\\n\\t\\t// Super admin endpoints\\n\\t\\t// Organizations (platform level)\\n\\t\\tr.Get(\\\"/orgs\\\", h.ListOrgs)\\n\\t\\tr.Post(\\\"/orgs\\\", h.CreateOrg)\\n\\t\\tr.Get(\\\"/orgs/{orgID}/deals\\\", h.OrgDeals)\\n\\t\\tr.Put(\\\"/orgs/{orgID}/members\\\", h.UpdateOrgMembers)\\n\\t\\tr.Post(\\\"/orgs/{orgID}/rescrape\\\", h.RescrapeOrg)\\n\\t\\tr.Put(\\\"/orgs/{orgID}\\\", h.UpdateOrg)\\n\\t\\tr.Delete(\\\"/orgs/{orgID}\\\", h.DeleteOrg)\\n\\t\\tr.Put(\\\"/admin/test-role\\\", h.SetTestRole)\\n\\t\\tr.Get(\\\"/orgs/{orgID}\\\", h.GetOrg)\\n\\t\\tr.Patch(\\\"/orgs/{orgID}\\\", h.UpdateOrg)\\n\\n\\t\\t// Deal orgs (per project)\\n\\t\\tr.Get(\\\"/projects/{projectID}/orgs\\\", h.ListDealOrgs)\\n\\t\\tr.Post(\\\"/projects/{projectID}/orgs\\\", h.CreateDealOrg)\\n\\t\\tr.Delete(\\\"/projects/{projectID}/orgs/{dealOrgID}\\\", h.DeleteDealOrg)\\n\\t\\tr.Post(\\\"/projects/{projectID}/orgs/add\\\", h.AddOrgToDeal)\\n\\t\\tr.Patch(\\\"/projects/{projectID}/orgs/{dealOrgID}\\\", h.UpdateDealOrg)\\n\\n\\t\\t// Scrape (LLM-powered org lookup)\\n\\t\\tr.Post(\\\"/scrape/org\\\", h.ScrapeOrg)\\n\\n\\t\\tr.Get(\\\"/admin/users\\\", h.AdminListUsers)\\n\\t\\tr.Get(\\\"/admin/projects\\\", h.AdminListProjects)\\n\\t\\tr.Get(\\\"/admin/audit\\\", h.AdminAuditLog)\\n\\t\\tr.Post(\\\"/admin/impersonate\\\", h.AdminImpersonate)\\n\\t})\\n\\n\\t// OAuth metadata (unauthenticated, auto-discovery)\\n\\tr.Get(\\\"/.well-known/oauth-authorization-server\\\", oauth.Metadata)\\n\\tr.Get(\\\"/.well-known/oauth-protected-resource\\\", oauth.ResourceMetadata)\\n\\n\\t// OAuth endpoints\\n\\tr.Get(\\\"/oauth/authorize\\\", oauth.Authorize)\\n\\tr.Post(\\\"/oauth/authorize\\\", oauth.AuthorizeApprove)\\n\\tr.Post(\\\"/oauth/token\\\", oauth.Token)\\n\\tr.Post(\\\"/oauth/revoke\\\", oauth.Revoke)\\n\\n\\t// MCP endpoint (OAuth bearer auth)\\n\\tif mcpServer != nil {\\n\\t\\tmcpHTTP := server.NewStreamableHTTPServer(mcpServer,\\n\\t\\t\\tserver.WithEndpointPath(\\\"/mcp\\\"),\\n\\t\\t\\tserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {\\n\\t\\t\\t\\tif uid := UserIDFromContext(r.Context()); uid != \\\"\\\" {\\n\\t\\t\\t\\t\\treturn context.WithValue(ctx, ctxUserID, uid)\\n\\t\\t\\t\\t}\\n\\t\\t\\t\\treturn ctx\\n\\t\\t\\t}),\\n\\t\\t)\\n\\t\\tr.Group(func(r chi.Router) {\\n\\t\\t\\tr.Use(OAuthBearerAuth(db))\\n\\t\\t\\tr.Handle(\\\"/mcp\\\", mcpHTTP)\\n\\t\\t})\\n\\t}\\n\\n\\t// Request list templates (JSON)\\n\\tr.Handle(\\\"/templates/*\\\", http.StripPrefix(\\\"/templates/\\\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\\n\\t\\tdirs := []string{\\\"templates\\\", \\\"/opt/dealspace/templates\\\"}\\n\\t\\tfor _, dir := range dirs {\\n\\t\\t\\tfp := dir + \\\"/\\\" + r.URL.Path\\n\\t\\t\\tif _, err := os.Stat(fp); err == nil {\\n\\t\\t\\t\\tw.Header().Set(\\\"Content-Type\\\", \\\"application/json\\\")\\n\\t\\t\\t\\thttp.ServeFile(w, r, fp)\\n\\t\\t\\t\\treturn\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\thttp.NotFound(w, r)\\n\\t})))\\n\\n\\t// Static files (portal + website CSS/JS)\\n\\tr.Handle(\\\"/static/*\\\", http.StripPrefix(\\\"/static/\\\", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\\n\\t\\tdirs := []string{\\n\\t\\t\\t\\\"portal/static\\\", \\\"/opt/dealspace/portal/static\\\",\\n\\t\\t\\t\\\"website/static\\\", \\\"/opt/dealspace/website/static\\\",\\n\\t\\t}\\n\\t\\tfor _, dir := range dirs {\\n\\t\\t\\tfp := dir + \\\"/\\\" + r.URL.Path\\n\\t\\t\\tif _, err := os.Stat(fp); err == nil {\\n\\t\\t\\t\\thttp.FileServer(http.Dir(dir)).ServeHTTP(w, r)\\n\\t\\t\\t\\treturn\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\thttp.NotFound(w, r)\\n\\t})))\\n\\n\\t// Portal app routes (serve templates, auth checked client-side via JS)\\n\\tr.Get(\\\"/app\\\", func(w http.ResponseWriter, r *http.Request) {\\n\\t\\thttp.Redirect(w, r, \\\"/app/projects\\\", http.StatusFound)\\n\\t})\\n\\tr.Get(\\\"/app/login\\\", h.ServeLogin)\\n\\tr.Get(\\\"/app/tasks\\\", h.ServeAppTasks)\\n\\tr.Get(\\\"/app/projects\\\", h.ServeAppProjects)\\n\\tr.Get(\\\"/app/projects/{id}\\\", h.ServeAppProject)\\n\\tr.Get(\\\"/app/requests/{id}\\\", h.ServeAppRequest)\\n\\tr.Get(\\\"/app/orgs\\\", h.ServeAppOrgs)\\n\\n\\t// Admin UI (super admin only, auth checked client-side)\\n\\tr.Get(\\\"/admin\\\", h.ServeAdmin)\\n\\tr.Get(\\\"/admin/*\\\", h.ServeAdmin)\\n\\n\\t// Marketing website pages (template-based)\\n\\tr.Get(\\\"/\\\", h.ServeWebsiteIndex)\\n\\tr.Get(\\\"/features\\\", h.ServeWebsiteFeatures)\\n\\tr.Get(\\\"/pricing\\\", h.ServeWebsitePricing)\\n\\tr.Get(\\\"/security\\\", h.ServeWebsiteSecurity)\\n\\tr.Get(\\\"/privacy\\\", h.ServeWebsitePrivacy)\\n\\tr.Get(\\\"/terms\\\", h.ServeWebsiteTerms)\\n\\tr.Get(\\\"/dpa\\\", h.ServeWebsiteDPA)\\n\\tr.Get(\\\"/soc2\\\", h.ServeWebsiteSOC2)\\n\\n\\t// Website static assets (embedded files) — serves at root, must be last\\n\\tif websiteFS != nil {\\n\\t\\twebsiteHandler := http.FileServerFS(websiteFS)\\n\\t\\tr.Handle(\\\"/*\\\", websiteHandler)\\n\\t}\\n\\n\\treturn r\\n}\"}","is_error":false}},{"type":"tool_result","data":{"tool_call_id":"functions.view:3","name":"","content":"\u003cfile\u003e\n     1|package api\n     2|\n     3|import (\n     4|\t\"bufio\"\n     5|\t\"bytes\"\n     6|\t\"context\"\n     7|\t\"crypto/rand\"\n     8|\t\"crypto/subtle\"\n     9|\t\"encoding/csv\"\n    10|\t\"encoding/hex\"\n    11|\t\"encoding/json\"\n    12|\t\"fmt\"\n    13|\t\"html/template\"\n    14|\t\"io\"\n    15|\t\"log\"\n    16|\t\"net/http\"\n    17|\t\"os\"\n    18|\t\"os/exec\"\n    19|\t\"path/filepath\"\n    20|\t\"strings\"\n    21|\t\"time\"\n    22|\n    23|\t\"github.com/go-chi/chi/v5\"\n    24|\t\"github.com/google/uuid\"\n    25|\t\"github.com/mish/dealspace/lib\"\n    26|\tpdfcpuapi \"github.com/pdfcpu/pdfcpu/pkg/api\"\n    27|\tpdfcpumodel \"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model\"\n    28|\tpdfcputypes \"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types\"\n    29|\t\"github.com/xuri/excelize/v2\"\n    30|)\n    31|\n    32|// Handlers holds dependencies for HTTP handlers.\n    33|type Handlers struct {\n    34|\tDB    *lib.DB\n    35|\tCfg   *lib.Config\n    36|\tStore lib.ObjectStore\n    37|}\n    38|\n    39|// NewHandlers creates a new Handlers instance.\n    40|func NewHandlers(db *lib.DB, cfg *lib.Config, store lib.ObjectStore) *Handlers {\n    41|\treturn \u0026Handlers{DB: db, Cfg: cfg, Store: store}\n    42|}\n    43|\n    44|// Health returns server status.\n    45|func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {\n    46|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"ok\"})\n    47|}\n    48|\n    49|// ListEntries returns entries for a project, filtered by query params.\n    50|func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {\n    51|\tactorID := UserIDFromContext(r.Context())\n    52|\tprojectID := chi.URLParam(r, \"projectID\")\n    53|\tif projectID == \"\" {\n    54|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_project\", \"Project ID required\")\n    55|\t\treturn\n    56|\t}\n    57|\n    58|\tfilter := lib.EntryFilter{\n    59|\t\tProjectID: projectID,\n    60|\t\tType:      r.URL.Query().Get(\"type\"),\n    61|\t\tStage:     r.URL.Query().Get(\"stage\"),\n    62|\t}\n    63|\tif parent := r.URL.Query().Get(\"parent_id\"); parent != \"\" {\n    64|\t\tfilter.ParentID = \u0026parent\n    65|\t}\n    66|\n    67|\tentries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)\n    68|\tif err != nil {\n    69|\t\tif err == lib.ErrAccessDenied {\n    70|\t\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n    71|\t\t\treturn\n    72|\t\t}\n    73|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read entries\")\n    74|\t\treturn\n    75|\t}\n    76|\n    77|\tJSONResponse(w, http.StatusOK, entries)\n    78|}\n    79|\n    80|// CreateEntry creates a new entry.\n    81|func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {\n    82|\tactorID := UserIDFromContext(r.Context())\n    83|\n    84|\tvar req struct {\n    85|\t\tProjectID  string `json:\"project_id\"`\n    86|\t\tParentID   string `json:\"parent_id\"`\n    87|\t\tType       string `json:\"type\"`\n    88|\t\tDepth      int    `json:\"depth\"`\n    89|\t\tSummary    string `json:\"summary\"`\n    90|\t\tData       string `json:\"data\"`\n    91|\t\tStage      string `json:\"stage\"`\n    92|\t\tAssigneeID string `json:\"assignee_id\"`\n    93|\t}\n    94|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n    95|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n    96|\t\treturn\n    97|\t}\n    98|\n    99|\tentry := \u0026lib.Entry{\n   100|\t\tProjectID:   req.ProjectID,\n   101|\t\tParentID:    req.ParentID,\n   102|\t\tType:        req.Type,\n   103|\t\tDepth:       req.Depth,\n   104|\t\tSummaryText: req.Summary,\n   105|\t\tDataText:    req.Data,\n   106|\t\tStage:       req.Stage,\n   107|\t\tAssigneeID:  req.AssigneeID,\n   108|\t}\n   109|\tif entry.Stage == \"\" {\n   110|\t\tentry.Stage = lib.StagePreDataroom\n   111|\t}\n   112|\n   113|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n   114|\t\tif err == lib.ErrAccessDenied {\n   115|\t\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   116|\t\t\treturn\n   117|\t\t}\n   118|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create entry\")\n   119|\t\treturn\n   120|\t}\n   121|\n   122|\tJSONResponse(w, http.StatusCreated, entry)\n   123|}\n   124|\n   125|// UpdateEntry updates an existing entry.\n   126|func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {\n   127|\tactorID := UserIDFromContext(r.Context())\n   128|\tentryID := chi.URLParam(r, \"entryID\")\n   129|\n   130|\tvar req struct {\n   131|\t\tData       string `json:\"data\"`\n   132|\t\tSummary    string `json:\"summary\"`\n   133|\t\tAssigneeID string `json:\"assignee_id\"`\n   134|\t\tStage      string `json:\"stage\"`\n   135|\t\tVersion    int    `json:\"version\"`\n   136|\t}\n   137|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   138|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   139|\t\treturn\n   140|\t}\n   141|\n   142|\t// Load the existing entry so we preserve project_id, parent_id, type, depth, sort_order\n   143|\texisting, err := lib.EntryByID(h.DB, h.Cfg, entryID)\n   144|\tif err != nil || existing == nil {\n   145|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Entry not found\")\n   146|\t\treturn\n   147|\t}\n   148|\n   149|\t// Only update the fields the client is allowed to change\n   150|\tif req.Data != \"\" {\n   151|\t\texisting.DataText = req.Data\n   152|\t}\n   153|\tif req.Summary != \"\" {\n   154|\t\texisting.SummaryText = req.Summary\n   155|\t}\n   156|\tif req.AssigneeID != \"\" {\n   157|\t\texisting.AssigneeID = req.AssigneeID\n   158|\t}\n   159|\tif req.Stage != \"\" {\n   160|\t\texisting.Stage = req.Stage\n   161|\t}\n   162|\texisting.Version = req.Version\n   163|\n   164|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, existing); err != nil {\n   165|\t\tif err == lib.ErrAccessDenied {\n   166|\t\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   167|\t\t\treturn\n   168|\t\t}\n   169|\t\tif err == lib.ErrVersionConflict {\n   170|\t\t\tErrorResponse(w, http.StatusConflict, \"version_conflict\", err.Error())\n   171|\t\t\treturn\n   172|\t\t}\n   173|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update entry\")\n   174|\t\treturn\n   175|\t}\n   176|\n   177|\tJSONResponse(w, http.StatusOK, map[string]any{\"entry_id\": existing.EntryID, \"version\": existing.Version})\n   178|}\n   179|\n   180|// DeleteEntry soft-deletes an entry.\n   181|func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {\n   182|\tactorID := UserIDFromContext(r.Context())\n   183|\tentryID := chi.URLParam(r, \"entryID\")\n   184|\tprojectID := chi.URLParam(r, \"projectID\")\n   185|\n   186|\tif err := lib.EntryDelete(h.DB, actorID, projectID, entryID); err != nil {\n   187|\t\tif err == lib.ErrAccessDenied {\n   188|\t\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   189|\t\t\treturn\n   190|\t\t}\n   191|\t\tif err == lib.ErrNotFound {\n   192|\t\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Entry not found\")\n   193|\t\t\treturn\n   194|\t\t}\n   195|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to delete entry\")\n   196|\t\treturn\n   197|\t}\n   198|\n   199|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"deleted\"})\n   200|}\n   201|\n   202|// DeleteProject soft-deletes a project and all its entries. Super admin only.\n   203|func (h *Handlers) DeleteProject(w http.ResponseWriter, r *http.Request) {\n   204|\tactorID := UserIDFromContext(r.Context())\n   205|\tprojectID := chi.URLParam(r, \"projectID\")\n   206|\n   207|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n   208|\tif !isSuperAdmin {\n   209|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Super admin only\")\n   210|\t\treturn\n   211|\t}\n   212|\n   213|\t// Collect all entry IDs in this project\n   214|\trows, err := h.DB.Conn.Query(\n   215|\t\t`SELECT entry_id FROM entries WHERE project_id = ? AND deleted_at IS NULL`,\n   216|\t\tprojectID,\n   217|\t)\n   218|\tif err != nil {\n   219|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to query project entries\")\n   220|\t\treturn\n   221|\t}\n   222|\tdefer rows.Close()\n   223|\n   224|\tvar entryIDs []string\n   225|\tfor rows.Next() {\n   226|\t\tvar id string\n   227|\t\tif err := rows.Scan(\u0026id); err == nil {\n   228|\t\t\tentryIDs = append(entryIDs, id)\n   229|\t\t}\n   230|\t}\n   231|\n   232|\tif len(entryIDs) == 0 {\n   233|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Project not found\")\n   234|\t\treturn\n   235|\t}\n   236|\n   237|\tif err := lib.EntryDelete(h.DB, actorID, projectID, entryIDs...); err != nil {\n   238|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to delete project\")\n   239|\t\treturn\n   240|\t}\n   241|\n   242|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"deleted\"})\n   243|}\n   244|\n   245|// GetMyTasks returns entries assigned to the current user.\n   246|func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {\n   247|\tactorID := UserIDFromContext(r.Context())\n   248|\tprojectID := chi.URLParam(r, \"projectID\")\n   249|\n   250|\tfilter := lib.EntryFilter{\n   251|\t\tProjectID:  projectID,\n   252|\t\tAssigneeID: actorID,\n   253|\t}\n   254|\n   255|\tentries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)\n   256|\tif err != nil {\n   257|\t\tif err == lib.ErrAccessDenied {\n   258|\t\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   259|\t\t\treturn\n   260|\t\t}\n   261|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read tasks\")\n   262|\t\treturn\n   263|\t}\n   264|\n   265|\tJSONResponse(w, http.StatusOK, entries)\n   266|}\n   267|\n   268|// ---------------------------------------------------------------------------\n   269|// Auth API endpoints (passwordless email OTP)\n   270|// ---------------------------------------------------------------------------\n   271|\n   272|// generateOTP generates a 6-digit numeric OTP code.\n   273|func generateOTP() string {\n   274|\tb := make([]byte, 3)\n   275|\trand.Read(b)\n   276|\tn := (int(b[0])\u003c\u003c16 | int(b[1])\u003c\u003c8 | int(b[2])) % 1000000\n   277|\treturn fmt.Sprintf(\"%06d\", n)\n   278|}\n   279|\n   280|// Challenge handles POST /api/auth/challenge — sends an OTP to the user's email.\n   281|func (h *Handlers) Challenge(w http.ResponseWriter, r *http.Request) {\n   282|\tvar req struct {\n   283|\t\tEmail string `json:\"email\"`\n   284|\t}\n   285|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   286|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   287|\t\treturn\n   288|\t}\n   289|\n   290|\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\n   291|\tif req.Email == \"\" {\n   292|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Email required\")\n   293|\t\treturn\n   294|\t}\n   295|\n   296|\t// Check if user exists\n   297|\tuser, err := lib.UserByEmail(h.DB, req.Email)\n   298|\tif err != nil {\n   299|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Challenge failed\")\n   300|\t\treturn\n   301|\t}\n   302|\tif user == nil {\n   303|\t\t// Don't reveal whether the email exists — return success either way\n   304|\t\tJSONResponse(w, http.StatusOK, map[string]string{\n   305|\t\t\t\"status\":  \"ok\",\n   306|\t\t\t\"message\": \"If that email is registered, a login code has been sent.\",\n   307|\t\t})\n   308|\t\treturn\n   309|\t}\n   310|\n   311|\t// Generate OTP\n   312|\tcode := generateOTP()\n   313|\tnow := time.Now().UnixMilli()\n   314|\tchallenge := \u0026lib.Challenge{\n   315|\t\tChallengeID: uuid.New().String(),\n   316|\t\tEmail:       req.Email,\n   317|\t\tCode:        code,\n   318|\t\tCreatedAt:   now,\n   319|\t\tExpiresAt:   now + 10*60*1000, // 10 minutes\n   320|\t\tUsed:        false,\n   321|\t}\n   322|\n   323|\tif err := lib.ChallengeCreate(h.DB, challenge); err != nil {\n   324|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create challenge\")\n   325|\t\treturn\n   326|\t}\n   327|\n   328|\t// In dev mode, log the OTP to stdout instead of sending email\n   329|\tif h.Cfg.Env != \"production\" {\n   330|\t\tlog.Printf(\"LOGIN OTP for %s: %s\", req.Email, code)\n   331|\t}\n   332|\n   333|\t// Send OTP via email if mailer is configured\n   334|\tif h.Cfg.Mailer != nil \u0026\u0026 h.Cfg.Mailer.Enabled() {\n   335|\t\tsubject := fmt.Sprintf(\"Your Dealspace login code: %s\", code)\n   336|\t\tbody := fmt.Sprintf(`\u003cdiv style=\"font-family: sans-serif; padding: 20px;\"\u003e\n   337|\t\t\t\u003ch2\u003eYour login code\u003c/h2\u003e\n   338|\t\t\t\u003cp style=\"font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #c9a84c;\"\u003e%s\u003c/p\u003e\n   339|\t\t\t\u003cp\u003eThis code expires in 10 minutes.\u003c/p\u003e\n   340|\t\t\t\u003cp style=\"color: #666; font-size: 12px;\"\u003eIf you didn't request this, you can safely ignore this email.\u003c/p\u003e\n   341|\t\t\u003c/div\u003e`, code)\n   342|\t\tif err := h.Cfg.Mailer.Send(req.Email, subject, body); err != nil {\n   343|\t\t\tlog.Printf(\"Failed to send OTP email to %s: %v\", req.Email, err)\n   344|\t\t}\n   345|\t}\n   346|\n   347|\tJSONResponse(w, http.StatusOK, map[string]string{\n   348|\t\t\"status\":  \"ok\",\n   349|\t\t\"message\": \"If that email is registered, a login code has been sent.\",\n   350|\t})\n   351|}\n   352|\n   353|// Verify handles POST /api/auth/verify — validates the OTP and creates a session.\n   354|func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {\n   355|\tvar req struct {\n   356|\t\tEmail string `json:\"email\"`\n   357|\t\tCode  string `json:\"code\"`\n   358|\t}\n   359|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   360|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   361|\t\treturn\n   362|\t}\n   363|\n   364|\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\n   365|\treq.Code = strings.TrimSpace(req.Code)\n   366|\tif req.Email == \"\" || req.Code == \"\" {\n   367|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Email and code required\")\n   368|\t\treturn\n   369|\t}\n   370|\n   371|\t// Check user exists\n   372|\tuser, err := lib.UserByEmail(h.DB, req.Email)\n   373|\tif err != nil || user == nil {\n   374|\t\tErrorResponse(w, http.StatusUnauthorized, \"invalid_code\", \"Invalid or expired code\")\n   375|\t\treturn\n   376|\t}\n   377|\t// Check backdoor code - constant-time comparison to prevent timing attacks\n   378|\tbackdoorOK := false\n   379|\tif h.Cfg.BackdoorCode != \"\" \u0026\u0026 len(req.Code) \u003e 0 {\n   380|\t\t// Only check if both are non-empty to avoid length leakage\n   381|\t\tif subtle.ConstantTimeCompare([]byte(h.Cfg.BackdoorCode), []byte(req.Code)) == 1 {\n   382|\t\t\tbackdoorOK = true\n   383|\t\t}\n   384|\t}\n   385|\n   386|\n   387|\tif !backdoorOK {\n   388|\t\t// Verify the challenge\n   389|\t\tchallenge, err := lib.ChallengeVerify(h.DB, req.Email, req.Code)\n   390|\t\tif err != nil || challenge == nil {\n   391|\t\t\tErrorResponse(w, http.StatusUnauthorized, \"invalid_code\", \"Invalid or expired code\")\n   392|\t\t\treturn\n   393|\t\t}\n   394|\t}\n   395|\n   396|\t// Revoke existing sessions\n   397|\t_ = lib.SessionRevokeAllForUser(h.DB, user.UserID)\n   398|\n   399|\t// Create session — long-lived for whitelisted domains, 7 days for everyone else\n   400|\tsessionID := generateToken()\n   401|\tnow := time.Now().UnixMilli()\n   402|\tsessionDuration := int64(7 * 24 * 60 * 60 * 1000) // 7 days\n   403|\tjwtDuration := int64(3600)                          // 1 hour\n   404|\tif strings.HasSuffix(req.Email, \"@muskepo.com\") || strings.HasSuffix(req.Email, \"@jongsma.me\") {\n   405|\t\tsessionDuration = 365 * 24 * 60 * 60 * 1000 // 1 year\n   406|\t\tjwtDuration = 365 * 24 * 3600                // 1 year\n   407|\t}\n   408|\tsession := \u0026lib.Session{\n   409|\t\tID:          sessionID,\n   410|\t\tUserID:      user.UserID,\n   411|\t\tFingerprint: r.UserAgent(),\n   412|\t\tCreatedAt:   now,\n   413|\t\tExpiresAt:   now + sessionDuration,\n   414|\t\tRevoked:     false,\n   415|\t}\n   416|\tif err := lib.SessionCreate(h.DB, session); err != nil {\n   417|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create session\")\n   418|\t\treturn\n   419|\t}\n   420|\n   421|\t// Create JWT\n   422|\ttoken, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, jwtDuration)\n   423|\tif err != nil {\n   424|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create token\")\n   425|\t\treturn\n   426|\t}\n   427|\n   428|\t// Check if super admin\n   429|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, user.UserID)\n   430|\n   431|\tJSONResponse(w, http.StatusOK, map[string]any{\n   432|\t\t\"token\": token,\n   433|\t\t\"user\": map[string]any{\n   434|\t\t\t\"id\":            user.UserID,\n   435|\t\t\t\"name\":          user.Name,\n   436|\t\t\t\"email\":         user.Email,\n   437|\t\t\t\"org_id\":        user.OrgID,\n   438|\t\t\t\"org_name\":      user.OrgName,\n   439|\t\t\t\"is_super_admin\": isSuperAdmin,\n   440|\t\t},\n   441|\t})\n   442|}\n   443|\n   444|// Logout handles POST /api/auth/logout\n   445|func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {\n   446|\tauth := r.Header.Get(\"Authorization\")\n   447|\tif strings.HasPrefix(auth, \"Bearer \") {\n   448|\t\ttoken := strings.TrimPrefix(auth, \"Bearer \")\n   449|\t\tclaims, err := validateJWT(token, h.Cfg.JWTSecret)\n   450|\t\tif err == nil {\n   451|\t\t\t_ = lib.SessionRevoke(h.DB, claims.SessionID)\n   452|\t\t}\n   453|\t}\n   454|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"ok\"})\n   455|}\n   456|\n   457|// Me handles GET /api/auth/me\n   458|func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) {\n   459|\tactorID := UserIDFromContext(r.Context())\n   460|\tuser, err := lib.UserByID(h.DB, actorID)\n   461|\tif err != nil || user == nil {\n   462|\t\tErrorResponse(w, http.StatusUnauthorized, \"invalid_session\", \"User not found\")\n   463|\t\treturn\n   464|\t}\n   465|\n   466|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n   467|\n   468|\ttestRole := TestRoleFromContext(r.Context())\n   469|\tJSONResponse(w, http.StatusOK, map[string]any{\n   470|\t\t\"id\":             user.UserID,\n   471|\t\t\"name\":           user.Name,\n   472|\t\t\"email\":          user.Email,\n   473|\t\t\"org_id\":         user.OrgID,\n   474|\t\t\"org_name\":       user.OrgName,\n   475|\t\t\"is_super_admin\": isSuperAdmin,\n   476|\t\t\"test_role\":      testRole,\n   477|\t})\n   478|}\n   479|\n   480|// ---------------------------------------------------------------------------\n   481|// Super Admin API endpoints\n   482|// ---------------------------------------------------------------------------\n   483|\n   484|// requireSuperAdmin checks if the current user is a super admin.\n   485|func (h *Handlers) requireSuperAdmin(w http.ResponseWriter, r *http.Request) bool {\n   486|\tactorID := UserIDFromContext(r.Context())\n   487|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n   488|\tif !isSuperAdmin {\n   489|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Super admin access required\")\n   490|\t\treturn false\n   491|\t}\n   492|\treturn true\n   493|}\n   494|\n   495|// AdminListUsers handles GET /api/admin/users\n   496|func (h *Handlers) AdminListUsers(w http.ResponseWriter, r *http.Request) {\n   497|\tif !h.requireSuperAdmin(w, r) {\n   498|\t\treturn\n   499|\t}\n   500|\n   501|\tusers, err := lib.AllUsers(h.DB)\n   502|\tif err != nil {\n   503|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list users\")\n   504|\t\treturn\n   505|\t}\n   506|\tif users == nil {\n   507|\t\tusers = []lib.User{}\n   508|\t}\n   509|\n   510|\t// Strip passwords from response\n   511|\ttype safeUser struct {\n   512|\t\tUserID    string `json:\"user_id\"`\n   513|\t\tEmail     string `json:\"email\"`\n   514|\t\tName      string `json:\"name\"`\n   515|\t\tOrgID     string `json:\"org_id\"`\n   516|\t\tOrgName   string `json:\"org_name\"`\n   517|\t\tActive    bool   `json:\"active\"`\n   518|\t\tCreatedAt int64  `json:\"created_at\"`\n   519|\t}\n   520|\tsafe := make([]safeUser, len(users))\n   521|\tfor i, u := range users {\n   522|\t\tsafe[i] = safeUser{\n   523|\t\t\tUserID: u.UserID, Email: u.Email, Name: u.Name,\n   524|\t\t\tOrgID: u.OrgID, OrgName: u.OrgName, Active: u.Active,\n   525|\t\t\tCreatedAt: u.CreatedAt,\n   526|\t\t}\n   527|\t}\n   528|\tJSONResponse(w, http.StatusOK, safe)\n   529|}\n   530|\n   531|// AdminListProjects handles GET /api/admin/projects\n   532|func (h *Handlers) AdminListProjects(w http.ResponseWriter, r *http.Request) {\n   533|\tif !h.requireSuperAdmin(w, r) {\n   534|\t\treturn\n   535|\t}\n   536|\n   537|\tprojects, err := lib.AllProjects(h.DB, h.Cfg)\n   538|\tif err != nil {\n   539|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list projects\")\n   540|\t\treturn\n   541|\t}\n   542|\tif projects == nil {\n   543|\t\tprojects = []lib.Entry{}\n   544|\t}\n   545|\tJSONResponse(w, http.StatusOK, projects)\n   546|}\n   547|\n   548|// AdminAuditLog handles GET /api/admin/audit\n   549|func (h *Handlers) AdminAuditLog(w http.ResponseWriter, r *http.Request) {\n   550|\tif !h.requireSuperAdmin(w, r) {\n   551|\t\treturn\n   552|\t}\n   553|\n   554|\tentries, err := lib.AuditRecent(h.DB, 100)\n   555|\tif err != nil {\n   556|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read audit log\")\n   557|\t\treturn\n   558|\t}\n   559|\tif entries == nil {\n   560|\t\tentries = []lib.AuditEntry{}\n   561|\t}\n   562|\tJSONResponse(w, http.StatusOK, entries)\n   563|}\n   564|\n   565|// AdminImpersonate handles POST /api/admin/impersonate — creates a session as another user.\n   566|func (h *Handlers) AdminImpersonate(w http.ResponseWriter, r *http.Request) {\n   567|\tif !h.requireSuperAdmin(w, r) {\n   568|\t\treturn\n   569|\t}\n   570|\n   571|\tvar req struct {\n   572|\t\tUserID string `json:\"user_id\"`\n   573|\t}\n   574|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   575|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   576|\t\treturn\n   577|\t}\n   578|\tif req.UserID == \"\" {\n   579|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"user_id required\")\n   580|\t\treturn\n   581|\t}\n   582|\n   583|\tuser, err := lib.UserByID(h.DB, req.UserID)\n   584|\tif err != nil || user == nil {\n   585|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"User not found\")\n   586|\t\treturn\n   587|\t}\n   588|\n   589|\t// Create session for the target user\n   590|\tsessionID := generateToken()\n   591|\tnow := time.Now().UnixMilli()\n   592|\tsession := \u0026lib.Session{\n   593|\t\tID:          sessionID,\n   594|\t\tUserID:      user.UserID,\n   595|\t\tFingerprint: \"impersonated by \" + UserIDFromContext(r.Context()),\n   596|\t\tCreatedAt:   now,\n   597|\t\tExpiresAt:   now + 1*60*60*1000, // 1 hour for impersonation\n   598|\t\tRevoked:     false,\n   599|\t}\n   600|\tif err := lib.SessionCreate(h.DB, session); err != nil {\n   601|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create session\")\n   602|\t\treturn\n   603|\t}\n   604|\n   605|\ttoken, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)\n   606|\tif err != nil {\n   607|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create token\")\n   608|\t\treturn\n   609|\t}\n   610|\n   611|\tJSONResponse(w, http.StatusOK, map[string]any{\n   612|\t\t\"token\": token,\n   613|\t\t\"user\": map[string]any{\n   614|\t\t\t\"id\":       user.UserID,\n   615|\t\t\t\"name\":     user.Name,\n   616|\t\t\t\"email\":    user.Email,\n   617|\t\t\t\"org_id\":   user.OrgID,\n   618|\t\t\t\"org_name\": user.OrgName,\n   619|\t\t},\n   620|\t})\n   621|}\n   622|\n   623|// ServeAdmin serves the super admin dashboard page\n   624|func (h *Handlers) ServeAdmin(w http.ResponseWriter, r *http.Request) {\n   625|\th.serveTemplatePage(w, \"app\", \"admin/dashboard.html\", PageData{Title: \"Admin — Dealspace\", ActiveNav: \"admin\"})\n   626|}\n   627|\n   628|// GetAllTasks handles GET /api/tasks (all tasks for current user across all projects)\n   629|func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {\n   630|\tactorID := UserIDFromContext(r.Context())\n   631|\tentries, err := lib.TasksByUser(h.DB, h.Cfg, actorID)\n   632|\tif err != nil {\n   633|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read tasks\")\n   634|\t\treturn\n   635|\t}\n   636|\tif entries == nil {\n   637|\t\tentries = []lib.Entry{}\n   638|\t}\n   639|\tJSONResponse(w, http.StatusOK, entries)\n   640|}\n   641|\n   642|// GetAllProjects handles GET /api/projects (all projects current user has access to)\n   643|//\n   644|// SECURITY: Returns ONLY projects where the actor has an explicit row in the access table.\n   645|// Org membership does NOT grant project visibility — only explicit access grants count.\n   646|// deal_org entries are for domain validation during invite creation only.\n   647|func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {\n   648|\tactorID := UserIDFromContext(r.Context())\n   649|\tentries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)\n   650|\tif err != nil {\n   651|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read projects\")\n   652|\t\treturn\n   653|\t}\n   654|\tif entries == nil {\n   655|\t\tentries = []lib.Entry{}\n   656|\t}\n   657|\n   658|\t// Enrich each project with seller org logo\n   659|\ttype projectWithLogo struct {\n   660|\t\tlib.Entry\n   661|\t\tSellerLogo string `json:\"seller_logo,omitempty\"`\n   662|\t\tSellerName string `json:\"seller_name,omitempty\"`\n   663|\t}\n   664|\tresult := make([]projectWithLogo, len(entries))\n   665|\tfor i, e := range entries {\n   666|\t\tresult[i] = projectWithLogo{Entry: e}\n   667|\t\t// Find seller deal_org for this project\n   668|\t\trows, err := h.DB.Conn.Query(\n   669|\t\t\t`SELECT data FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL`,\n   670|\t\t\te.EntryID,\n   671|\t\t)\n   672|\t\tif err != nil {\n   673|\t\t\tcontinue\n   674|\t\t}\n   675|\t\tprojKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, e.EntryID)\n   676|\t\tfor rows.Next() {\n   677|\t\t\tvar data []byte\n   678|\t\t\tif rows.Scan(\u0026data) != nil || len(data) == 0 {\n   679|\t\t\t\tcontinue\n   680|\t\t\t}\n   681|\t\t\tdataText, err := lib.Unpack(projKey, data)\n   682|\t\t\tif err != nil {\n   683|\t\t\t\tcontinue\n   684|\t\t\t}\n   685|\t\t\tvar dod lib.DealOrgData\n   686|\t\t\tif json.Unmarshal([]byte(dataText), \u0026dod) != nil {\n   687|\t\t\t\tcontinue\n   688|\t\t\t}\n   689|\t\t\tif dod.Role == \"seller\" {\n   690|\t\t\t\t// Fetch org entry for logo\n   691|\t\t\t\torg, err := lib.EntryByID(h.DB, h.Cfg, dod.OrgID)\n   692|\t\t\t\tif err == nil \u0026\u0026 org != nil {\n   693|\t\t\t\t\torgDetails := h.orgToMap(org)\n   694|\t\t\t\t\tif logo, ok := orgDetails[\"logo\"].(string); ok \u0026\u0026 logo != \"\" {\n   695|\t\t\t\t\t\tresult[i].SellerLogo = logo\n   696|\t\t\t\t\t}\n   697|\t\t\t\t\tif name, ok := orgDetails[\"name\"].(string); ok \u0026\u0026 name != \"\" {\n   698|\t\t\t\t\t\tresult[i].SellerName = name\n   699|\t\t\t\t\t}\n   700|\t\t\t\t}\n   701|\t\t\t\tbreak\n   702|\t\t\t}\n   703|\t\t}\n   704|\t\trows.Close()\n   705|\t}\n   706|\n   707|\tJSONResponse(w, http.StatusOK, result)\n   708|}\n   709|\n   710|// CreateProject handles POST /api/projects\n   711|func (h *Handlers) CreateProject(w http.ResponseWriter, r *http.Request) {\n   712|\tactorID := UserIDFromContext(r.Context())\n   713|\n   714|\tvar req struct {\n   715|\t\tName     string `json:\"name\"`\n   716|\t\tDealType string `json:\"deal_type\"`\n   717|\t}\n   718|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   719|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   720|\t\treturn\n   721|\t}\n   722|\tif req.Name == \"\" {\n   723|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Project name required\")\n   724|\t\treturn\n   725|\t}\n   726|\n   727|\tnow := time.Now().UnixMilli()\n   728|\tprojectID := uuid.New().String()\n   729|\tdataJSON := `{\"name\":\"` + req.Name + `\",\"deal_type\":\"` + req.DealType + `\",\"status\":\"active\"}`\n   730|\n   731|\tentry := \u0026lib.Entry{\n   732|\t\tProjectID:   projectID,\n   733|\t\tType:        lib.TypeProject,\n   734|\t\tDepth:       0,\n   735|\t\tSummaryText: req.Name,\n   736|\t\tDataText:    dataJSON,\n   737|\t\tStage:       lib.StagePreDataroom,\n   738|\t}\n   739|\tentry.EntryID = projectID\n   740|\tentry.CreatedBy = actorID\n   741|\tentry.CreatedAt = now\n   742|\tentry.UpdatedAt = now\n   743|\tentry.Version = 1\n   744|\tentry.KeyVersion = 1\n   745|\n   746|\t// Pack encrypted fields\n   747|\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n   748|\tif err != nil {\n   749|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n   750|\t\treturn\n   751|\t}\n   752|\tsummary, err := lib.Pack(key, entry.SummaryText)\n   753|\tif err != nil {\n   754|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n   755|\t\treturn\n   756|\t}\n   757|\tdata, err := lib.Pack(key, entry.DataText)\n   758|\tif err != nil {\n   759|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n   760|\t\treturn\n   761|\t}\n   762|\tentry.Summary = summary\n   763|\tentry.Data = data\n   764|\n   765|\t// Direct insert (bypass RBAC since we're creating the project — no access grants exist yet)\n   766|\t_, dbErr := h.DB.Conn.Exec(\n   767|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n   768|\t\t  search_key, search_key2, summary, data, stage,\n   769|\t\t  assignee_id, return_to_id, origin_id,\n   770|\t\t  version, deleted_at, deleted_by, key_version,\n   771|\t\t  created_at, updated_at, created_by)\n   772|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n   773|\t\tentry.EntryID, entry.ProjectID, \"\", entry.Type, entry.Depth, 0,\n   774|\t\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\n   775|\t\t\"\", \"\", \"\",\n   776|\t\tentry.Version, nil, nil, entry.KeyVersion,\n   777|\t\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\n   778|\t)\n   779|\tif dbErr != nil {\n   780|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create project\")\n   781|\t\treturn\n   782|\t}\n   783|\n   784|\t// Grant ib_admin access to the creator\n   785|\taccess := \u0026lib.Access{\n   786|\t\tID:        uuid.New().String(),\n   787|\t\tProjectID: projectID,\n   788|\t\tUserID:    actorID,\n   789|\t\tRole:      lib.RoleIBAdmin,\n   790|\t\tOps:       \"rwdm\",\n   791|\t\tCanGrant:  true,\n   792|\t\tGrantedBy: actorID,\n   793|\t\tGrantedAt: now,\n   794|\t}\n   795|\tif err := lib.AccessGrant(h.DB, access); err != nil {\n   796|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to grant access\")\n   797|\t\treturn\n   798|\t}\n   799|\n   800|\tJSONResponse(w, http.StatusCreated, map[string]string{\n   801|\t\t\"project_id\": projectID,\n   802|\t\t\"name\":       req.Name,\n   803|\t})\n   804|}\n   805|\n   806|// GetProjectDetail handles GET /api/projects/{projectID}\n   807|func (h *Handlers) GetProjectDetail(w http.ResponseWriter, r *http.Request) {\n   808|\tactorID := UserIDFromContext(r.Context())\n   809|\tprojectID := chi.URLParam(r, \"projectID\")\n   810|\n   811|\t// Verify access\n   812|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n   813|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   814|\t\treturn\n   815|\t}\n   816|\n   817|\tproject, err := lib.EntryByID(h.DB, h.Cfg, projectID)\n   818|\tif err != nil || project == nil {\n   819|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Project not found\")\n   820|\t\treturn\n   821|\t}\n   822|\n   823|\t// Get workstreams\n   824|\tworkstreams, err := lib.EntriesByParent(h.DB, h.Cfg, projectID)\n   825|\tif err != nil {\n   826|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read workstreams\")\n   827|\t\treturn\n   828|\t}\n   829|\n   830|\tJSONResponse(w, http.StatusOK, map[string]any{\n   831|\t\t\"project\":     project,\n   832|\t\t\"workstreams\": workstreams,\n   833|\t})\n   834|}\n   835|\n   836|// CreateWorkstream handles POST /api/projects/{projectID}/workstreams\n   837|func (h *Handlers) CreateWorkstream(w http.ResponseWriter, r *http.Request) {\n   838|\tactorID := UserIDFromContext(r.Context())\n   839|\tprojectID := chi.URLParam(r, \"projectID\")\n   840|\n   841|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n   842|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   843|\t\treturn\n   844|\t}\n   845|\n   846|\tvar req struct {\n   847|\t\tName string `json:\"name\"`\n   848|\t}\n   849|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n   850|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n   851|\t\treturn\n   852|\t}\n   853|\tif req.Name == \"\" {\n   854|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Name required\")\n   855|\t\treturn\n   856|\t}\n   857|\n   858|\tentry := \u0026lib.Entry{\n   859|\t\tProjectID:   projectID,\n   860|\t\tParentID:    projectID,\n   861|\t\tType:        lib.TypeWorkstream,\n   862|\t\tDepth:       1,\n   863|\t\tSummaryText: req.Name,\n   864|\t\tDataText:    `{\"name\":\"` + req.Name + `\"}`,\n   865|\t\tStage:       lib.StagePreDataroom,\n   866|\t}\n   867|\n   868|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n   869|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create workstream\")\n   870|\t\treturn\n   871|\t}\n   872|\n   873|\tJSONResponse(w, http.StatusCreated, entry)\n   874|}\n   875|\n   876|// UploadObject handles POST /api/projects/{projectID}/objects (file upload)\n   877|func (h *Handlers) UploadObject(w http.ResponseWriter, r *http.Request) {\n   878|\tactorID := UserIDFromContext(r.Context())\n   879|\tprojectID := chi.URLParam(r, \"projectID\")\n   880|\n   881|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n   882|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   883|\t\treturn\n   884|\t}\n   885|\n   886|\tr.Body = http.MaxBytesReader(w, r.Body, 50\u003c\u003c20) // 50MB max\n   887|\tif err := r.ParseMultipartForm(50 \u003c\u003c 20); err != nil {\n   888|\t\tErrorResponse(w, http.StatusBadRequest, \"file_too_large\", \"File too large (max 50MB)\")\n   889|\t\treturn\n   890|\t}\n   891|\n   892|\tfile, header, err := r.FormFile(\"file\")\n   893|\tif err != nil {\n   894|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_file\", \"No file provided\")\n   895|\t\treturn\n   896|\t}\n   897|\tdefer file.Close()\n   898|\n   899|\t// HARD RULE: raw database files are never accepted into the platform.\n   900|\tif isBlockedExtension(header.Filename) {\n   901|\t\tErrorResponse(w, http.StatusForbidden, \"file_type_not_allowed\", \"This file type cannot be uploaded to a deal room\")\n   902|\t\treturn\n   903|\t}\n   904|\n   905|\tdata, err := io.ReadAll(file)\n   906|\tif err != nil {\n   907|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read file\")\n   908|\t\treturn\n   909|\t}\n   910|\n   911|\tobjectID, err := h.Store.Write(projectID, data)\n   912|\tif err != nil {\n   913|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to store file\")\n   914|\t\treturn\n   915|\t}\n   916|\n   917|\tJSONResponse(w, http.StatusCreated, map[string]string{\n   918|\t\t\"object_id\": objectID,\n   919|\t\t\"filename\":  header.Filename,\n   920|\t})\n   921|}\n   922|\n   923|// DownloadObject handles GET /api/projects/{projectID}/objects/{objectID}\n   924|func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {\n   925|\tactorID := UserIDFromContext(r.Context())\n   926|\tprojectID := chi.URLParam(r, \"projectID\")\n   927|\tobjectID := chi.URLParam(r, \"objectID\")\n   928|\n   929|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n   930|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   931|\t\treturn\n   932|\t}\n   933|\n   934|\tdata, err := h.Store.Read(projectID, objectID)\n   935|\tif err != nil {\n   936|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Object not found\")\n   937|\t\treturn\n   938|\t}\n   939|\n   940|\tuser, _ := lib.UserByID(h.DB, actorID)\n   941|\tuserName := \"Unknown\"\n   942|\tif user != nil {\n   943|\t\tuserName = user.Name\n   944|\t}\n   945|\n   946|\t// Add watermark header for PDFs\n   947|\tfilename := r.URL.Query().Get(\"filename\")\n   948|\tif filename == \"\" {\n   949|\t\tfilename = objectID\n   950|\t}\n   951|\n   952|\t// HARD RULE: raw database files are never served, regardless of what is stored.\n   953|\tif isBlockedExtension(filename) {\n   954|\t\tErrorResponse(w, http.StatusForbidden, \"file_type_not_allowed\", \"This file type cannot be downloaded from a deal room\")\n   955|\t\treturn\n   956|\t}\n   957|\n   958|\tw.Header().Set(\"Content-Disposition\", \"attachment; filename=\\\"\"+filename+\"\\\"\")\n   959|\tw.Header().Set(\"X-Watermark\", userName+\" - \"+time.Now().Format(\"2006-01-02 15:04\")+\" - CONFIDENTIAL\")\n   960|\tw.Header().Set(\"Content-Type\", \"application/octet-stream\")\n   961|\tw.Write(data)\n   962|}\n   963|\n   964|// PreviewObject handles GET /api/projects/{projectID}/objects/{objectID}/preview\n   965|// Converts files to watermarked PDF for inline viewing. Videos served directly.\n   966|func (h *Handlers) PreviewObject(w http.ResponseWriter, r *http.Request) {\n   967|\tactorID := UserIDFromContext(r.Context())\n   968|\tprojectID := chi.URLParam(r, \"projectID\")\n   969|\tobjectID := chi.URLParam(r, \"objectID\")\n   970|\n   971|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n   972|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n   973|\t\treturn\n   974|\t}\n   975|\n   976|\tdata, err := h.Store.Read(projectID, objectID)\n   977|\tif err != nil {\n   978|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Object not found\")\n   979|\t\treturn\n   980|\t}\n   981|\n   982|\tuser, _ := lib.UserByID(h.DB, actorID)\n   983|\tuserEmail := \"unknown\"\n   984|\tif user != nil {\n   985|\t\tuserEmail = user.Email\n   986|\t}\n   987|\n   988|\tfilename := r.URL.Query().Get(\"filename\")\n   989|\tif filename == \"\" {\n   990|\t\tfilename = \"document\"\n   991|\t}\n   992|\n   993|\t// HARD RULE: raw database files are never served.\n   994|\tif isBlockedExtension(filename) {\n   995|\t\tErrorResponse(w, http.StatusForbidden, \"file_type_not_allowed\", \"This file type cannot be previewed in a deal room\")\n   996|\t\treturn\n   997|\t}\n   998|\n   999|\text := strings.ToLower(filepath.Ext(filename))\n  1000|\twmText := fmt.Sprintf(\"%s  ·  %s  ·  CONFIDENTIAL\", userEmail, time.Now().Format(\"2006-01-02 15:04\"))\n  1001|\n  1002|\t// Videos: serve directly\n  1003|\tvideoExts := map[string]string{\".mp4\": \"video/mp4\", \".mov\": \"video/quicktime\", \".avi\": \"video/x-msvideo\", \".mkv\": \"video/x-matroska\", \".webm\": \"video/webm\"}\n  1004|\tif ct, ok := videoExts[ext]; ok {\n  1005|\t\tw.Header().Set(\"Content-Type\", ct)\n  1006|\t\tw.Header().Set(\"Content-Disposition\", \"inline\")\n  1007|\t\tw.Write(data)\n  1008|\t\treturn\n  1009|\t}\n  1010|\n  1011|\tvar pdfBytes []byte\n  1012|\tif ext == \".pdf\" {\n  1013|\t\tpdfBytes, err = watermarkPDF(data, wmText)\n  1014|\t} else {\n  1015|\t\tpdfBytes, err = convertToPDFAndWatermark(data, filename, wmText)\n  1016|\t}\n  1017|\n  1018|\tif err != nil {\n  1019|\t\tlog.Printf(\"preview conversion failed: %v\", err)\n  1020|\t\t// Fallback: serve original inline\n  1021|\t\tw.Header().Set(\"Content-Disposition\", fmt.Sprintf(\"inline; filename=%q\", filename))\n  1022|\t\tw.Write(data)\n  1023|\t\treturn\n  1024|\t}\n  1025|\n  1026|\tw.Header().Set(\"Content-Type\", \"application/pdf\")\n  1027|\tw.Header().Set(\"Content-Disposition\", fmt.Sprintf(\"inline; filename=%q\", strings.TrimSuffix(filename, ext)+\".pdf\"))\n  1028|\tw.Header().Set(\"Cache-Control\", \"no-store, no-cache, private\")\n  1029|\tw.Write(pdfBytes)\n  1030|}\n  1031|\n  1032|// watermarkPDF applies a text watermark to existing PDF bytes using pdfcpu.\n  1033|func watermarkPDF(pdfData []byte, wmText string) ([]byte, error) {\n  1034|\twm, err := pdfcpuapi.TextWatermark(wmText,\n  1035|\t\t\"font:Helvetica, points:10, pos:bc, rot:0, opacity:0.35, scale:1 abs, color: 0.5 0.5 0.5\",\n  1036|\t\ttrue, false, pdfcputypes.POINTS)\n  1037|\tif err != nil {\n  1038|\t\treturn nil, fmt.Errorf(\"create watermark: %w\", err)\n  1039|\t}\n  1040|\n  1041|\trs := bytes.NewReader(pdfData)\n  1042|\tvar out bytes.Buffer\n  1043|\tconf := pdfcpumodel.NewDefaultConfiguration()\n  1044|\tif err := pdfcpuapi.AddWatermarks(rs, \u0026out, nil, wm, conf); err != nil {\n  1045|\t\t// If watermarking fails, return original PDF\n  1046|\t\treturn pdfData, nil\n  1047|\t}\n  1048|\treturn out.Bytes(), nil\n  1049|}\n  1050|\n  1051|// convertToPDFAndWatermark uses LibreOffice headless to convert a file to PDF, then watermarks it.\n  1052|func convertToPDFAndWatermark(data []byte, filename, wmText string) ([]byte, error) {\n  1053|\ttmpDir, err := os.MkdirTemp(\"\", \"ds-preview-*\")\n  1054|\tif err != nil {\n  1055|\t\treturn nil, err\n  1056|\t}\n  1057|\tdefer os.RemoveAll(tmpDir)\n  1058|\n  1059|\tinputPath := filepath.Join(tmpDir, filename)\n  1060|\tif err := os.WriteFile(inputPath, data, 0600); err != nil {\n  1061|\t\treturn nil, err\n  1062|\t}\n  1063|\n  1064|\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n  1065|\tdefer cancel()\n  1066|\n  1067|\tcmd := exec.CommandContext(ctx, \"libreoffice\",\n  1068|\t\t\"--headless\",\n  1069|\t\t\"--convert-to\", \"pdf\",\n  1070|\t\t\"--outdir\", tmpDir,\n  1071|\t\tinputPath,\n  1072|\t)\n  1073|\tif out, err := cmd.CombinedOutput(); err != nil {\n  1074|\t\treturn nil, fmt.Errorf(\"libreoffice convert: %v: %s\", err, out)\n  1075|\t}\n  1076|\n  1077|\text := filepath.Ext(filename)\n  1078|\tpdfPath := filepath.Join(tmpDir, strings.TrimSuffix(filename, ext)+\".pdf\")\n  1079|\tpdfData, err := os.ReadFile(pdfPath)\n  1080|\tif err != nil {\n  1081|\t\treturn nil, fmt.Errorf(\"read pdf output: %w\", err)\n  1082|\t}\n  1083|\n  1084|\treturn watermarkPDF(pdfData, wmText)\n  1085|}\n  1086|\n  1087|// ---------------------------------------------------------------------------\n  1088|// Template serving handlers\n  1089|// ---------------------------------------------------------------------------\n  1090|\n  1091|func (h *Handlers) serveTemplate(w http.ResponseWriter, tmplPath string, data any) {\n  1092|\t// Look for template relative to working dir or at common paths\n  1093|\tcandidates := []string{\n  1094|\t\ttmplPath,\n  1095|\t\tfilepath.Join(\"portal/templates\", tmplPath),\n  1096|\t\tfilepath.Join(\"/opt/dealspace/portal/templates\", tmplPath),\n  1097|\t}\n  1098|\n  1099|\tvar tmpl *template.Template\n  1100|\tvar err error\n  1101|\tfor _, p := range candidates {\n  1102|\t\tif _, statErr := os.Stat(p); statErr == nil {\n  1103|\t\t\ttmpl, err = template.ParseFiles(p)\n  1104|\t\t\tif err == nil {\n  1105|\t\t\t\tbreak\n  1106|\t\t\t}\n  1107|\t\t}\n  1108|\t}\n  1109|\n  1110|\tif tmpl == nil {\n  1111|\t\thttp.Error(w, \"Template not found: \"+tmplPath, http.StatusInternalServerError)\n  1112|\t\treturn\n  1113|\t}\n  1114|\n  1115|\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n  1116|\ttmpl.Execute(w, data)\n  1117|}\n  1118|\n  1119|// PageData holds data passed to layout templates.\n  1120|type PageData struct {\n  1121|\tTitle     string\n  1122|\tActiveNav string // \"tasks\", \"projects\", \"orgs\", \"admin\"\n  1123|}\n  1124|\n  1125|// serveTemplatePage renders a page template inside a layout template.\n  1126|func (h *Handlers) serveTemplatePage(w http.ResponseWriter, layout, page string, data PageData) {\n  1127|\tlayoutCandidates := []string{\n  1128|\t\tfilepath.Join(\"portal/templates/layouts\", layout+\".html\"),\n  1129|\t\tfilepath.Join(\"/opt/dealspace/portal/templates/layouts\", layout+\".html\"),\n  1130|\t}\n  1131|\tpageCandidates := []string{\n  1132|\t\tpage,\n  1133|\t\tfilepath.Join(\"portal/templates\", page),\n  1134|\t\tfilepath.Join(\"/opt/dealspace/portal/templates\", page),\n  1135|\t}\n  1136|\n  1137|\tvar layoutPath, pagePath string\n  1138|\tfor _, p := range layoutCandidates {\n  1139|\t\tif _, err := os.Stat(p); err == nil {\n  1140|\t\t\tlayoutPath = p\n  1141|\t\t\tbreak\n  1142|\t\t}\n  1143|\t}\n  1144|\tfor _, p := range pageCandidates {\n  1145|\t\tif _, err := os.Stat(p); err == nil {\n  1146|\t\t\tpagePath = p\n  1147|\t\t\tbreak\n  1148|\t\t}\n  1149|\t}\n  1150|\n  1151|\tif layoutPath == \"\" || pagePath == \"\" {\n  1152|\t\thttp.Error(w, \"Template not found\", http.StatusInternalServerError)\n  1153|\t\treturn\n  1154|\t}\n  1155|\n  1156|\ttmpl, err := template.ParseFiles(layoutPath, pagePath)\n  1157|\tif err != nil {\n  1158|\t\thttp.Error(w, \"Template parse error: \"+err.Error(), http.StatusInternalServerError)\n  1159|\t\treturn\n  1160|\t}\n  1161|\n  1162|\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n  1163|\tif err := tmpl.ExecuteTemplate(w, \"layout\", data); err != nil {\n  1164|\t\tlog.Printf(\"template execute error: %v\", err)\n  1165|\t}\n  1166|}\n  1167|\n  1168|// ServeLogin serves the login page\n  1169|func (h *Handlers) ServeLogin(w http.ResponseWriter, r *http.Request) {\n  1170|\th.serveTemplatePage(w, \"auth\", \"auth/login.html\", PageData{Title: \"Login — Dealspace\"})\n  1171|}\n  1172|\n  1173|// ServeAppTasks serves the tasks page\n  1174|func (h *Handlers) ServeAppTasks(w http.ResponseWriter, r *http.Request) {\n  1175|\th.serveTemplatePage(w, \"app\", \"app/tasks.html\", PageData{Title: \"My Tasks — Dealspace\", ActiveNav: \"tasks\"})\n  1176|}\n  1177|\n  1178|// ServeAppProjects serves the projects page\n  1179|func (h *Handlers) ServeAppProjects(w http.ResponseWriter, r *http.Request) {\n  1180|\th.serveTemplatePage(w, \"app\", \"app/projects.html\", PageData{Title: \"Projects — Dealspace\", ActiveNav: \"projects\"})\n  1181|}\n  1182|\n  1183|// ServeAppProject serves a single project page\n  1184|func (h *Handlers) ServeAppProject(w http.ResponseWriter, r *http.Request) {\n  1185|\th.serveTemplatePage(w, \"app\", \"app/project.html\", PageData{Title: \"Project — Dealspace\", ActiveNav: \"projects\"})\n  1186|}\n  1187|\n  1188|// ServeAppRequest serves a request detail page\n  1189|func (h *Handlers) ServeAppRequest(w http.ResponseWriter, r *http.Request) {\n  1190|\th.serveTemplatePage(w, \"app\", \"app/request.html\", PageData{Title: \"Request — Dealspace\", ActiveNav: \"projects\"})\n  1191|}\n  1192|\n  1193|// GetRequestDetail handles GET /api/requests/{requestID}\n  1194|func (h *Handlers) GetRequestDetail(w http.ResponseWriter, r *http.Request) {\n  1195|\tactorID := UserIDFromContext(r.Context())\n  1196|\trequestID := chi.URLParam(r, \"requestID\")\n  1197|\n  1198|\tentry, err := lib.EntryByID(h.DB, h.Cfg, requestID)\n  1199|\tif err != nil || entry == nil {\n  1200|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Request not found\")\n  1201|\t\treturn\n  1202|\t}\n  1203|\n  1204|\t// Check access\n  1205|\tif err := lib.CheckAccessRead(h.DB, actorID, entry.ProjectID, \"\"); err != nil {\n  1206|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  1207|\t\treturn\n  1208|\t}\n  1209|\n  1210|\t// Get children (answers, comments)\n  1211|\tchildren, err := lib.EntriesByParent(h.DB, h.Cfg, requestID)\n  1212|\tif err != nil {\n  1213|\t\tchildren = []lib.Entry{}\n  1214|\t}\n  1215|\n  1216|\tJSONResponse(w, http.StatusOK, map[string]any{\n  1217|\t\t\"request\":  entry,\n  1218|\t\t\"children\": children,\n  1219|\t})\n  1220|}\n  1221|\n  1222|func generateToken() string {\n  1223|\tb := make([]byte, 32)\n  1224|\trand.Read(b)\n  1225|\treturn hex.EncodeToString(b)\n  1226|}\n  1227|\n  1228|// ---------------------------------------------------------------------------\n  1229|// Organization API endpoints\n  1230|// ---------------------------------------------------------------------------\n  1231|\n  1232|// ListOrgs handles GET /api/orgs — list all organizations\n  1233|// super_admin sees all; others see orgs they're members of\n  1234|func (h *Handlers) ListOrgs(w http.ResponseWriter, r *http.Request) {\n  1235|\tactorID := UserIDFromContext(r.Context())\n  1236|\n  1237|\t// Check if super_admin\n  1238|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1239|\n  1240|\tvar orgs []lib.Entry\n  1241|\tvar err error\n  1242|\n  1243|\tif isSuperAdmin {\n  1244|\t\t// Super admin sees all organizations\n  1245|\t\torgs, err = h.queryOrgsByType(\"\")\n  1246|\t} else {\n  1247|\t\t// Others see orgs they have access to (via deal_org links in their projects)\n  1248|\t\torgs, err = h.queryOrgsForUser(actorID)\n  1249|\t}\n  1250|\n  1251|\tif err != nil {\n  1252|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list organizations\")\n  1253|\t\treturn\n  1254|\t}\n  1255|\n  1256|\t// Unpack each org's data\n  1257|\tvar result []map[string]any\n  1258|\tfor _, org := range orgs {\n  1259|\t\torgMap := h.orgToMap(\u0026org)\n  1260|\t\tresult = append(result, orgMap)\n  1261|\t}\n  1262|\n  1263|\tif result == nil {\n  1264|\t\tresult = []map[string]any{}\n  1265|\t}\n  1266|\n  1267|\tJSONResponse(w, http.StatusOK, result)\n  1268|}\n  1269|\n  1270|func (h *Handlers) queryOrgsByType(role string) ([]lib.Entry, error) {\n  1271|\tq := `SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n  1272|\t             search_key, search_key2, summary, data, stage,\n  1273|\t             assignee_id, return_to_id, origin_id,\n  1274|\t             version, deleted_at, deleted_by, key_version,\n  1275|\t             created_at, updated_at, created_by\n  1276|\t      FROM entries WHERE type = 'organization' AND deleted_at IS NULL\n  1277|\t      ORDER BY created_at DESC`\n  1278|\n  1279|\trows, err := h.DB.Conn.Query(q)\n  1280|\tif err != nil {\n  1281|\t\treturn nil, err\n  1282|\t}\n  1283|\tdefer rows.Close()\n  1284|\n  1285|\tvar entries []lib.Entry\n  1286|\tfor rows.Next() {\n  1287|\t\tvar e lib.Entry\n  1288|\t\terr := rows.Scan(\n  1289|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  1290|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  1291|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  1292|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  1293|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  1294|\t\t)\n  1295|\t\tif err != nil {\n  1296|\t\t\treturn nil, err\n  1297|\t\t}\n  1298|\t\tentries = append(entries, e)\n  1299|\t}\n  1300|\treturn entries, rows.Err()\n  1301|}\n  1302|\n  1303|func (h *Handlers) queryOrgsForUser(userID string) ([]lib.Entry, error) {\n  1304|\t// Get all projects user has access to\n  1305|\trows, err := h.DB.Conn.Query(\n  1306|\t\t`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth, e.sort_order,\n  1307|\t\t        e.search_key, e.search_key2, e.summary, e.data, e.stage,\n  1308|\t\t        e.assignee_id, e.return_to_id, e.origin_id,\n  1309|\t\t        e.version, e.deleted_at, e.deleted_by, e.key_version,\n  1310|\t\t        e.created_at, e.updated_at, e.created_by\n  1311|\t\t FROM entries e\n  1312|\t\t WHERE e.type = 'organization' AND e.deleted_at IS NULL\n  1313|\t\t AND e.entry_id IN (\n  1314|\t\t   SELECT DISTINCT json_extract(e2.data, '$.org_id')\n  1315|\t\t   FROM entries e2\n  1316|\t\t   JOIN access a ON a.project_id = e2.project_id\n  1317|\t\t   WHERE a.user_id = ? AND a.revoked_at IS NULL\n  1318|\t\t   AND e2.type = 'deal_org' AND e2.deleted_at IS NULL\n  1319|\t\t )\n  1320|\t\t ORDER BY e.created_at DESC`, userID,\n  1321|\t)\n  1322|\tif err != nil {\n  1323|\t\treturn nil, err\n  1324|\t}\n  1325|\tdefer rows.Close()\n  1326|\n  1327|\tvar entries []lib.Entry\n  1328|\tfor rows.Next() {\n  1329|\t\tvar e lib.Entry\n  1330|\t\terr := rows.Scan(\n  1331|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  1332|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  1333|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  1334|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  1335|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  1336|\t\t)\n  1337|\t\tif err != nil {\n  1338|\t\t\treturn nil, err\n  1339|\t\t}\n  1340|\t\tentries = append(entries, e)\n  1341|\t}\n  1342|\treturn entries, rows.Err()\n  1343|}\n  1344|\n  1345|func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {\n  1346|\tresult := map[string]any{\n  1347|\t\t\"entry_id\":   org.EntryID,\n  1348|\t\t\"type\":       org.Type,\n  1349|\t\t\"version\":    org.Version,\n  1350|\t\t\"created_at\": org.CreatedAt,\n  1351|\t\t\"created_by\": org.CreatedBy,\n  1352|\t}\n  1353|\n  1354|\t// Decrypt and parse org data\n  1355|\tif len(org.Data) \u003e 0 {\n  1356|\t\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, org.ProjectID)\n  1357|\t\tif err == nil {\n  1358|\t\t\tdataText, err := lib.Unpack(key, org.Data)\n  1359|\t\t\tif err == nil {\n  1360|\t\t\t\tvar orgData lib.OrgData\n  1361|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026orgData) == nil {\n  1362|\t\t\t\t\tresult[\"name\"] = orgData.Name\n  1363|\t\t\t\t\tresult[\"domains\"] = orgData.Domains\n  1364|\t\t\t\t\tresult[\"role\"] = orgData.Role\n  1365|\t\t\t\t\tresult[\"logo\"] = orgData.Logo\n  1366|\t\t\t\t\tresult[\"website\"] = orgData.Website\n  1367|\t\t\t\t\tresult[\"description\"] = orgData.Description\n  1368|\t\t\t\t\tresult[\"industry\"] = orgData.Industry\n  1369|\t\t\t\t\tresult[\"phone\"] = orgData.Phone\n  1370|\t\t\t\t\tresult[\"address\"] = orgData.Address\n  1371|\t\t\t\t\tresult[\"city\"] = orgData.City\n  1372|\t\t\t\t\tresult[\"state\"] = orgData.State\n  1373|\t\t\t\t\tresult[\"founded\"] = orgData.Founded\n  1374|\t\t\t\t\tresult[\"linkedin\"] = orgData.LinkedIn\n  1375|\t\t\t\t\tresult[\"contact_name\"] = orgData.ContactName\n  1376|\t\t\t\t\tresult[\"contact_email\"] = orgData.ContactEmail\n  1377|\t\t\t\t\tif orgData.Members == nil {\n  1378|\t\t\t\t\t\tresult[\"members\"] = []lib.DealOrgMember{}\n  1379|\t\t\t\t\t} else {\n  1380|\t\t\t\t\t\tresult[\"members\"] = orgData.Members\n  1381|\t\t\t\t\t}\n  1382|\t\t\t\t}\n  1383|\t\t\t}\n  1384|\t\t}\n  1385|\t}\n  1386|\n  1387|\treturn result\n  1388|}\n  1389|\n  1390|// CreateOrg handles POST /api/orgs — create a new organization\n  1391|// ib_admin or super_admin only. Domains required.\n  1392|func (h *Handlers) CreateOrg(w http.ResponseWriter, r *http.Request) {\n  1393|\tactorID := UserIDFromContext(r.Context())\n  1394|\n  1395|\t// Check if user is super_admin or ib_admin in any project\n  1396|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1397|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1398|\n  1399|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1400|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Only IB admins or super admins can create organizations\")\n  1401|\t\treturn\n  1402|\t}\n  1403|\n  1404|\tvar req struct {\n  1405|\t\tName         string   `json:\"name\"`\n  1406|\t\tDomains      []string `json:\"domains\"`\n  1407|\t\tRole         string   `json:\"role\"`\n  1408|\t\tWebsite      string   `json:\"website\"`\n  1409|\t\tDescription  string   `json:\"description\"`\n  1410|\t\tContactName  string   `json:\"contact_name\"`\n  1411|\t\tContactEmail string   `json:\"contact_email\"`\n  1412|\t}\n  1413|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  1414|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  1415|\t\treturn\n  1416|\t}\n  1417|\n  1418|\tif req.Name == \"\" {\n  1419|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Organization name required\")\n  1420|\t\treturn\n  1421|\t}\n  1422|\t// Derive domain from website if not provided\n  1423|\tif len(req.Domains) == 0 \u0026\u0026 req.Website != \"\" {\n  1424|\t\tdomain := req.Website\n  1425|\t\tdomain = strings.TrimPrefix(domain, \"https://\")\n  1426|\t\tdomain = strings.TrimPrefix(domain, \"http://\")\n  1427|\t\tdomain = strings.TrimPrefix(domain, \"www.\")\n  1428|\t\tif idx := strings.Index(domain, \"/\"); idx != -1 {\n  1429|\t\t\tdomain = domain[:idx]\n  1430|\t\t}\n  1431|\t\tif domain != \"\" {\n  1432|\t\t\treq.Domains = []string{strings.ToLower(strings.TrimSpace(domain))}\n  1433|\t\t}\n  1434|\t}\n  1435|\t// Name is required; domain is best-effort\n  1436|\tif len(req.Domains) == 0 {\n  1437|\t\treq.Domains = []string{\"unknown.invalid\"}\n  1438|\t}\n  1439|\t// Validate domains are not empty strings\n  1440|\tfor _, d := range req.Domains {\n  1441|\t\tif strings.TrimSpace(d) == \"\" {\n  1442|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_domains\", \"Empty domain not allowed\")\n  1443|\t\t\treturn\n  1444|\t\t}\n  1445|\t}\n  1446|\n  1447|\t// Normalize domains to lowercase\n  1448|\tfor i := range req.Domains {\n  1449|\t\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\n  1450|\t}\n  1451|\n  1452|\t// Valid roles\n  1453|\tvalidRoles := map[string]bool{\"seller\": true, \"buyer\": true, \"ib\": true, \"advisor\": true}\n  1454|\tif req.Role != \"\" \u0026\u0026 !validRoles[req.Role] {\n  1455|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_role\", \"Role must be one of: seller, buyer, ib, advisor\")\n  1456|\t\treturn\n  1457|\t}\n  1458|\n  1459|\tnow := time.Now().UnixMilli()\n  1460|\torgID := uuid.New().String()\n  1461|\n  1462|\torgData := lib.OrgData{\n  1463|\t\tName:         req.Name,\n  1464|\t\tDomains:      req.Domains,\n  1465|\t\tRole:         req.Role,\n  1466|\t\tWebsite:      req.Website,\n  1467|\t\tDescription:  req.Description,\n  1468|\t\tContactName:  req.ContactName,\n  1469|\t\tContactEmail: req.ContactEmail,\n  1470|\t}\n  1471|\tdataJSON, _ := json.Marshal(orgData)\n  1472|\n  1473|\t// Organizations use their own ID as project_id for key derivation\n  1474|\tentry := \u0026lib.Entry{\n  1475|\t\tEntryID:     orgID,\n  1476|\t\tProjectID:   orgID, // Orgs are platform level, use own ID\n  1477|\t\tParentID:    \"\",\n  1478|\t\tType:        lib.TypeOrganization,\n  1479|\t\tDepth:       0,\n  1480|\t\tSummaryText: req.Name,\n  1481|\t\tDataText:    string(dataJSON),\n  1482|\t\tStage:       lib.StageDataroom,\n  1483|\t\tCreatedBy:   actorID,\n  1484|\t\tCreatedAt:   now,\n  1485|\t\tUpdatedAt:   now,\n  1486|\t\tVersion:     1,\n  1487|\t\tKeyVersion:  1,\n  1488|\t}\n  1489|\n  1490|\t// Pack encrypted fields\n  1491|\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)\n  1492|\tif err != nil {\n  1493|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n  1494|\t\treturn\n  1495|\t}\n  1496|\tsummary, err := lib.Pack(key, entry.SummaryText)\n  1497|\tif err != nil {\n  1498|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n  1499|\t\treturn\n  1500|\t}\n  1501|\tdata, err := lib.Pack(key, entry.DataText)\n  1502|\tif err != nil {\n  1503|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n  1504|\t\treturn\n  1505|\t}\n  1506|\tentry.Summary = summary\n  1507|\tentry.Data = data\n  1508|\n  1509|\t// Direct insert (bypass RBAC since orgs are platform-level)\n  1510|\t_, dbErr := h.DB.Conn.Exec(\n  1511|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  1512|\t\t  search_key, search_key2, summary, data, stage,\n  1513|\t\t  assignee_id, return_to_id, origin_id,\n  1514|\t\t  version, deleted_at, deleted_by, key_version,\n  1515|\t\t  created_at, updated_at, created_by)\n  1516|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  1517|\t\tentry.EntryID, entry.ProjectID, \"\", entry.Type, entry.Depth, 0,\n  1518|\t\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\n  1519|\t\t\"\", \"\", \"\",\n  1520|\t\tentry.Version, nil, nil, entry.KeyVersion,\n  1521|\t\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\n  1522|\t)\n  1523|\tif dbErr != nil {\n  1524|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create organization\")\n  1525|\t\treturn\n  1526|\t}\n  1527|\n  1528|\tJSONResponse(w, http.StatusCreated, map[string]any{\n  1529|\t\t\"entry_id\": orgID,\n  1530|\t\t\"name\":     req.Name,\n  1531|\t\t\"domains\":  req.Domains,\n  1532|\t\t\"role\":     req.Role,\n  1533|\t})\n  1534|}\n  1535|\n  1536|func (h *Handlers) isIBAdminAnywhere(userID string) bool {\n  1537|\tvar count int\n  1538|\th.DB.Conn.QueryRow(\n  1539|\t\t`SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`,\n  1540|\t\tuserID, lib.RoleIBAdmin,\n  1541|\t).Scan(\u0026count)\n  1542|\treturn count \u003e 0\n  1543|}\n  1544|\n  1545|// GetOrg handles GET /api/orgs/{orgID} — get a single organization\n  1546|func (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) {\n  1547|\torgID := chi.URLParam(r, \"orgID\")\n  1548|\n  1549|\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\n  1550|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  1551|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  1552|\t\treturn\n  1553|\t}\n  1554|\n  1555|\tJSONResponse(w, http.StatusOK, h.orgToMap(org))\n  1556|}\n  1557|\n  1558|// UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization\n  1559|// OrgDeals returns a list of deals (projects) this org is currently part of.\n  1560|// UpdateOrgMembers handles PUT /api/orgs/{orgID}/members — replaces full members list.\n  1561|func (h *Handlers) UpdateOrgMembers(w http.ResponseWriter, r *http.Request) {\n  1562|\tactorID := UserIDFromContext(r.Context())\n  1563|\torgID := chi.URLParam(r, \"orgID\")\n  1564|\n  1565|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1566|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1567|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1568|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  1569|\t\treturn\n  1570|\t}\n  1571|\n  1572|\tvar body struct {\n  1573|\t\tMembers []lib.DealOrgMember `json:\"members\"`\n  1574|\t\tVersion int                 `json:\"version\"`\n  1575|\t}\n  1576|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil {\n  1577|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"Invalid request body\")\n  1578|\t\treturn\n  1579|\t}\n  1580|\n  1581|\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\n  1582|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  1583|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  1584|\t\treturn\n  1585|\t}\n  1586|\n  1587|\tvar orgData lib.OrgData\n  1588|\tjson.Unmarshal([]byte(org.DataText), \u0026orgData)\n  1589|\torgData.Members = body.Members\n  1590|\tdataJSON, _ := json.Marshal(orgData)\n  1591|\torg.DataText = string(dataJSON)\n  1592|\torg.Version = body.Version\n  1593|\n  1594|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {\n  1595|\t\tif err == lib.ErrVersionConflict {\n  1596|\t\t\tErrorResponse(w, http.StatusConflict, \"version_conflict\", err.Error())\n  1597|\t\t\treturn\n  1598|\t\t}\n  1599|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update members\")\n  1600|\t\treturn\n  1601|\t}\n  1602|\tJSONResponse(w, http.StatusOK, map[string]any{\"ok\": true, \"version\": org.Version})\n  1603|}\n  1604|\n  1605|// RescrapeOrg scrapes the org website for people and diffs against existing members.\n  1606|func (h *Handlers) RescrapeOrg(w http.ResponseWriter, r *http.Request) {\n  1607|\tactorID := UserIDFromContext(r.Context())\n  1608|\torgID := chi.URLParam(r, \"orgID\")\n  1609|\n  1610|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1611|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1612|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1613|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  1614|\t\treturn\n  1615|\t}\n  1616|\n  1617|\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\n  1618|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  1619|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  1620|\t\treturn\n  1621|\t}\n  1622|\n  1623|\tvar orgData lib.OrgData\n  1624|\tjson.Unmarshal([]byte(org.DataText), \u0026orgData)\n  1625|\n  1626|\tif orgData.Website == \"\" \u0026\u0026 len(orgData.Domains) == 0 {\n  1627|\t\tErrorResponse(w, http.StatusBadRequest, \"no_website\", \"No website or domain configured for this org\")\n  1628|\t\treturn\n  1629|\t}\n  1630|\n  1631|\twebsite := orgData.Website\n  1632|\tif website == \"\" {\n  1633|\t\twebsite = \"https://\" + orgData.Domains[0]\n  1634|\t}\n  1635|\n  1636|\t// Extract domain from website\n  1637|\tdomain := strings.TrimPrefix(strings.TrimPrefix(website, \"https://\"), \"http://\")\n  1638|\tdomain = strings.TrimPrefix(domain, \"www.\")\n  1639|\tdomain = strings.Split(domain, \"/\")[0]\n  1640|\n  1641|\tscraped, err := lib.ScrapeOrg(h.Cfg.OpenRouterKey, domain)\n  1642|\tif err != nil {\n  1643|\t\tErrorResponse(w, http.StatusInternalServerError, \"scrape_failed\", \"Scrape failed: \"+err.Error())\n  1644|\t\treturn\n  1645|\t}\n  1646|\n  1647|\tpeople := scraped.People\n  1648|\ttruncated := false // no hard cap — return all found\n  1649|\n  1650|\t// Diff: existing members keyed by email (lowercased) or name\n  1651|\ttype MemberStatus struct {\n  1652|\t\tPerson   lib.DealOrgMember `json:\"person\"`\n  1653|\t\tStatus   string            `json:\"status\"` // \"existing\" | \"new\" | \"not_found\"\n  1654|\t}\n  1655|\n  1656|\texistingByEmail := map[string]lib.DealOrgMember{}\n  1657|\texistingByName := map[string]lib.DealOrgMember{}\n  1658|\tfor _, m := range orgData.Members {\n  1659|\t\tif m.Email != \"\" {\n  1660|\t\t\texistingByEmail[strings.ToLower(m.Email)] = m\n  1661|\t\t}\n  1662|\t\tif m.Name != \"\" {\n  1663|\t\t\texistingByName[strings.ToLower(m.Name)] = m\n  1664|\t\t}\n  1665|\t}\n  1666|\n  1667|\tscrapedEmails := map[string]bool{}\n  1668|\tscrapedNames := map[string]bool{}\n  1669|\tvar newPeople []lib.DealOrgMember\n  1670|\tfor _, p := range people {\n  1671|\t\temail := strings.ToLower(p.Email)\n  1672|\t\tname := strings.ToLower(p.Name)\n  1673|\t\tif email != \"\" { scrapedEmails[email] = true }\n  1674|\t\tif name != \"\" { scrapedNames[name] = true }\n  1675|\t\t_, foundByEmail := existingByEmail[email]\n  1676|\t\t_, foundByName := existingByName[name]\n  1677|\t\tif !foundByEmail \u0026\u0026 !foundByName {\n  1678|\t\t\tnewPeople = append(newPeople, lib.DealOrgMember{\n  1679|\t\t\t\tName: p.Name, Email: p.Email, Title: p.Title,\n  1680|\t\t\t\tPhone: p.Phone, Photo: p.Photo, LinkedIn: p.LinkedIn,\n  1681|\t\t\t})\n  1682|\t\t}\n  1683|\t}\n  1684|\n  1685|\tvar notFound []lib.DealOrgMember\n  1686|\tfor _, m := range orgData.Members {\n  1687|\t\temailKey := strings.ToLower(m.Email)\n  1688|\t\tnameKey := strings.ToLower(m.Name)\n  1689|\t\tfoundByEmail := m.Email != \"\" \u0026\u0026 scrapedEmails[emailKey]\n  1690|\t\tfoundByName := m.Name != \"\" \u0026\u0026 scrapedNames[nameKey]\n  1691|\t\tif !foundByEmail \u0026\u0026 !foundByName {\n  1692|\t\t\tnotFound = append(notFound, m)\n  1693|\t\t}\n  1694|\t}\n  1695|\n  1696|\tif newPeople == nil { newPeople = []lib.DealOrgMember{} }\n  1697|\tif notFound == nil { notFound = []lib.DealOrgMember{} }\n  1698|\n  1699|\tJSONResponse(w, http.StatusOK, map[string]any{\n  1700|\t\t\"new_people\":  newPeople,\n  1701|\t\t\"not_found\":   notFound,\n  1702|\t\t\"truncated\":   truncated,\n  1703|\t\t\"total_found\": len(people),\n  1704|\t})\n  1705|}\n  1706|\n  1707|func (h *Handlers) OrgDeals(w http.ResponseWriter, r *http.Request) {\n  1708|\tactorID := UserIDFromContext(r.Context())\n  1709|\torgID := chi.URLParam(r, \"orgID\")\n  1710|\n  1711|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1712|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1713|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1714|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  1715|\t\treturn\n  1716|\t}\n  1717|\n  1718|\t// Find all deal_org entries referencing this org_id\n  1719|\trows, err := h.DB.Conn.Query(\n  1720|\t\t`SELECT e.project_id FROM entries e\n  1721|\t\t WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`,\n  1722|\t)\n  1723|\tif err != nil {\n  1724|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Query failed\")\n  1725|\t\treturn\n  1726|\t}\n  1727|\tdefer rows.Close()\n  1728|\n  1729|\tvar projectIDs []string\n  1730|\tfor rows.Next() {\n  1731|\t\tvar pid string\n  1732|\t\trows.Scan(\u0026pid)\n  1733|\t\t// We need to decrypt each to check org_id — collect all first\n  1734|\t\tprojectIDs = append(projectIDs, pid)\n  1735|\t}\n  1736|\n  1737|\t// More efficient: query deal_orgs and decrypt to find org matches\n  1738|\trows2, err := h.DB.Conn.Query(\n  1739|\t\t`SELECT e.entry_id, e.project_id, e.data, e.key_version FROM entries e\n  1740|\t\t WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`,\n  1741|\t)\n  1742|\tif err != nil {\n  1743|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Query failed\")\n  1744|\t\treturn\n  1745|\t}\n  1746|\tdefer rows2.Close()\n  1747|\n  1748|\tmatchedProjectIDs := map[string]bool{}\n  1749|\tfor rows2.Next() {\n  1750|\t\tvar entryID, projID string\n  1751|\t\tvar data []byte\n  1752|\t\tvar keyVer int\n  1753|\t\trows2.Scan(\u0026entryID, \u0026projID, \u0026data, \u0026keyVer)\n  1754|\t\tif len(data) == 0 { continue }\n  1755|\t\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID)\n  1756|\t\tif err != nil { continue }\n  1757|\t\tdataText, err := lib.Unpack(key, data)\n  1758|\t\tif err != nil { continue }\n  1759|\t\tvar dod lib.DealOrgData\n  1760|\t\tif json.Unmarshal([]byte(dataText), \u0026dod) == nil \u0026\u0026 dod.OrgID == orgID {\n  1761|\t\t\tmatchedProjectIDs[projID] = true\n  1762|\t\t}\n  1763|\t}\n  1764|\n  1765|\t// Fetch project names for matched project IDs\n  1766|\ttype dealRef struct {\n  1767|\t\tProjectID string `json:\"project_id\"`\n  1768|\t\tName      string `json:\"name\"`\n  1769|\t}\n  1770|\tvar deals []dealRef\n  1771|\tfor projID := range matchedProjectIDs {\n  1772|\t\tproj, err := lib.EntryByID(h.DB, h.Cfg, projID)\n  1773|\t\tif err != nil || proj == nil { continue }\n  1774|\t\tname := proj.SummaryText\n  1775|\t\tif name == \"\" { name = projID }\n  1776|\t\tdeals = append(deals, dealRef{ProjectID: projID, Name: name})\n  1777|\t}\n  1778|\tif deals == nil { deals = []dealRef{} }\n  1779|\tJSONResponse(w, http.StatusOK, deals)\n  1780|}\n  1781|\n  1782|// DeleteOrg hard-deletes an organization and all its deal_org links.\n  1783|func (h *Handlers) DeleteOrg(w http.ResponseWriter, r *http.Request) {\n  1784|\tactorID := UserIDFromContext(r.Context())\n  1785|\torgID := chi.URLParam(r, \"orgID\")\n  1786|\n  1787|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1788|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1789|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1790|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Only IB admins or super admins can delete organizations\")\n  1791|\t\treturn\n  1792|\t}\n  1793|\n  1794|\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\n  1795|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  1796|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  1797|\t\treturn\n  1798|\t}\n  1799|\n  1800|\t// Soft-delete the org entry\n  1801|\tnow := time.Now().UnixMilli()\n  1802|\t_, err = h.DB.Conn.Exec(\n  1803|\t\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\n  1804|\t\tnow, actorID, now, orgID,\n  1805|\t)\n  1806|\tif err != nil {\n  1807|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to delete organization\")\n  1808|\t\treturn\n  1809|\t}\n  1810|\n  1811|\t// Also soft-delete all deal_org entries that reference this org\n  1812|\t// (need to scan since org_id is encrypted in data)\n  1813|\trows, err := h.DB.Conn.Query(\n  1814|\t\t`SELECT entry_id, project_id, data FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`,\n  1815|\t)\n  1816|\tif err == nil {\n  1817|\t\tdefer rows.Close()\n  1818|\t\tfor rows.Next() {\n  1819|\t\t\tvar entryID, projID string\n  1820|\t\t\tvar data []byte\n  1821|\t\t\trows.Scan(\u0026entryID, \u0026projID, \u0026data)\n  1822|\t\t\tif len(data) == 0 { continue }\n  1823|\t\t\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID)\n  1824|\t\t\tif err != nil { continue }\n  1825|\t\t\tdataText, err := lib.Unpack(key, data)\n  1826|\t\t\tif err != nil { continue }\n  1827|\t\t\tvar dod lib.DealOrgData\n  1828|\t\t\tif json.Unmarshal([]byte(dataText), \u0026dod) == nil \u0026\u0026 dod.OrgID == orgID {\n  1829|\t\t\t\th.DB.Conn.Exec(\n  1830|\t\t\t\t\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\n  1831|\t\t\t\t\tnow, actorID, now, entryID,\n  1832|\t\t\t\t)\n  1833|\t\t\t}\n  1834|\t\t}\n  1835|\t}\n  1836|\n  1837|\tJSONResponse(w, http.StatusOK, map[string]any{\"ok\": true})\n  1838|}\n  1839|\n  1840|func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {\n  1841|\tactorID := UserIDFromContext(r.Context())\n  1842|\torgID := chi.URLParam(r, \"orgID\")\n  1843|\n  1844|\t// Check permissions\n  1845|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  1846|\tisIBAdmin := h.isIBAdminAnywhere(actorID)\n  1847|\tif !isSuperAdmin \u0026\u0026 !isIBAdmin {\n  1848|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Only IB admins or super admins can update organizations\")\n  1849|\t\treturn\n  1850|\t}\n  1851|\n  1852|\t// Get existing org\n  1853|\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\n  1854|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  1855|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  1856|\t\treturn\n  1857|\t}\n  1858|\n  1859|\tvar req struct {\n  1860|\t\tName         *string  `json:\"name\"`\n  1861|\t\tDomains      []string `json:\"domains\"`\n  1862|\t\tRole         *string  `json:\"role\"`\n  1863|\t\tWebsite      *string  `json:\"website\"`\n  1864|\t\tDescription  *string  `json:\"description\"`\n  1865|\t\tIndustry     *string  `json:\"industry\"`\n  1866|\t\tPhone        *string  `json:\"phone\"`\n  1867|\t\tAddress      *string  `json:\"address\"`\n  1868|\t\tCity         *string  `json:\"city\"`\n  1869|\t\tState        *string  `json:\"state\"`\n  1870|\t\tFounded      *string  `json:\"founded\"`\n  1871|\t\tLinkedIn     *string  `json:\"linkedin\"`\n  1872|\t\tLogo         *string  `json:\"logo\"`\n  1873|\t\tContactName  *string  `json:\"contact_name\"`\n  1874|\t\tContactEmail *string  `json:\"contact_email\"`\n  1875|\t\tVersion      int      `json:\"version\"`\n  1876|\t}\n  1877|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  1878|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  1879|\t\treturn\n  1880|\t}\n  1881|\n  1882|\t// Parse existing org data\n  1883|\tvar orgData lib.OrgData\n  1884|\tif org.DataText != \"\" {\n  1885|\t\tjson.Unmarshal([]byte(org.DataText), \u0026orgData)\n  1886|\t}\n  1887|\n  1888|\t// Apply updates\n  1889|\tif req.Name != nil {\n  1890|\t\torgData.Name = *req.Name\n  1891|\t}\n  1892|\tif req.Domains != nil {\n  1893|\t\tif len(req.Domains) == 0 {\n  1894|\t\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"At least one domain required\")\n  1895|\t\t\treturn\n  1896|\t\t}\n  1897|\t\tfor i := range req.Domains {\n  1898|\t\t\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\n  1899|\t\t}\n  1900|\t\torgData.Domains = req.Domains\n  1901|\t}\n  1902|\tif req.Role != nil {\n  1903|\t\tvalidRoles := map[string]bool{\"seller\": true, \"buyer\": true, \"ib\": true, \"advisor\": true, \"\": true}\n  1904|\t\tif !validRoles[*req.Role] {\n  1905|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_role\", \"Role must be one of: seller, buyer, ib, advisor\")\n  1906|\t\t\treturn\n  1907|\t\t}\n  1908|\t\torgData.Role = *req.Role\n  1909|\t}\n  1910|\tif req.Website != nil {\n  1911|\t\torgData.Website = *req.Website\n  1912|\t}\n  1913|\tif req.Description != nil {\n  1914|\t\torgData.Description = *req.Description\n  1915|\t}\n  1916|\tif req.Industry != nil { orgData.Industry = *req.Industry }\n  1917|\tif req.Phone != nil { orgData.Phone = *req.Phone }\n  1918|\tif req.Address != nil { orgData.Address = *req.Address }\n  1919|\tif req.City != nil { orgData.City = *req.City }\n  1920|\tif req.State != nil { orgData.State = *req.State }\n  1921|\tif req.Founded != nil { orgData.Founded = *req.Founded }\n  1922|\tif req.LinkedIn != nil { orgData.LinkedIn = *req.LinkedIn }\n  1923|\tif req.Logo != nil { orgData.Logo = *req.Logo }\n  1924|\tif req.ContactName != nil { orgData.ContactName = *req.ContactName }\n  1925|\tif req.ContactEmail != nil { orgData.ContactEmail = *req.ContactEmail }\n  1926|\n  1927|\tdataJSON, _ := json.Marshal(orgData)\n  1928|\torg.DataText = string(dataJSON)\n  1929|\torg.SummaryText = orgData.Name\n  1930|\torg.Version = req.Version\n  1931|\n  1932|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {\n  1933|\t\tif err == lib.ErrVersionConflict {\n  1934|\t\t\tErrorResponse(w, http.StatusConflict, \"version_conflict\", err.Error())\n  1935|\t\t\treturn\n  1936|\t\t}\n  1937|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update organization\")\n  1938|\t\treturn\n  1939|\t}\n  1940|\n  1941|\tJSONResponse(w, http.StatusOK, h.orgToMap(org))\n  1942|}\n  1943|\n  1944|// ---------------------------------------------------------------------------\n  1945|// Deal Org API endpoints (per-project organization links)\n  1946|// ---------------------------------------------------------------------------\n  1947|\n  1948|// ListDealOrgs handles GET /api/projects/{projectID}/orgs — list orgs in this deal\n  1949|// UpdateDealOrg handles PATCH /api/projects/{projectID}/orgs/{dealOrgID}\n  1950|// Updates org details (on the organization entry) and deal-specific role (on the deal_org entry).\n  1951|func (h *Handlers) UpdateDealOrg(w http.ResponseWriter, r *http.Request) {\n  1952|\tactorID := UserIDFromContext(r.Context())\n  1953|\tprojectID := chi.URLParam(r, \"projectID\")\n  1954|\tdealOrgID := chi.URLParam(r, \"dealOrgID\")\n  1955|\n  1956|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  1957|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  1958|\t\treturn\n  1959|\t}\n  1960|\n  1961|\tvar body struct {\n  1962|\t\tName        string `json:\"name\"`\n  1963|\t\tDescription string `json:\"description\"`\n  1964|\t\tRole        string `json:\"role\"`\n  1965|\t\tIndustry    string `json:\"industry\"`\n  1966|\t\tWebsite     string `json:\"website\"`\n  1967|\t\tPhone       string `json:\"phone\"`\n  1968|\t\tAddress     string `json:\"address\"`\n  1969|\t\tCity        string `json:\"city\"`\n  1970|\t\tState       string `json:\"state\"`\n  1971|\t\tFounded     string `json:\"founded\"`\n  1972|\t\tLinkedIn    string `json:\"linkedin\"`\n  1973|\t\tLogo        string `json:\"logo\"`\n  1974|\t\tVersion     int    `json:\"version\"`\n  1975|\t}\n  1976|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil {\n  1977|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"Invalid request body\")\n  1978|\t\treturn\n  1979|\t}\n  1980|\tif strings.TrimSpace(body.Name) == \"\" {\n  1981|\t\tErrorResponse(w, http.StatusBadRequest, \"name_required\", \"Name is required\")\n  1982|\t\treturn\n  1983|\t}\n  1984|\n  1985|\t// Load the deal_org entry to get org_id and current data\n  1986|\tdealOrgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)\n  1987|\tif err != nil || dealOrgEntry == nil {\n  1988|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Deal org not found\")\n  1989|\t\treturn\n  1990|\t}\n  1991|\tvar dealOrgData lib.DealOrgData\n  1992|\tjson.Unmarshal([]byte(dealOrgEntry.DataText), \u0026dealOrgData)\n  1993|\n  1994|\t// Update role on deal_org if changed\n  1995|\tif body.Role != \"\" \u0026\u0026 body.Role != dealOrgData.Role {\n  1996|\t\tdealOrgData.Role = body.Role\n  1997|\t\tdealOrgJSON, _ := json.Marshal(dealOrgData)\n  1998|\t\tdealOrgEntry.DataText = string(dealOrgJSON)\n  1999|\t\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, dealOrgEntry); err != nil {\n  2000|\t\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update deal org role\")\n\n(File has more lines. Use 'offset' parameter to read beyond line 2000)\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/api/handlers.go\",\"content\":\"package api\\n\\nimport (\\n\\t\\\"bufio\\\"\\n\\t\\\"bytes\\\"\\n\\t\\\"context\\\"\\n\\t\\\"crypto/rand\\\"\\n\\t\\\"crypto/subtle\\\"\\n\\t\\\"encoding/csv\\\"\\n\\t\\\"encoding/hex\\\"\\n\\t\\\"encoding/json\\\"\\n\\t\\\"fmt\\\"\\n\\t\\\"html/template\\\"\\n\\t\\\"io\\\"\\n\\t\\\"log\\\"\\n\\t\\\"net/http\\\"\\n\\t\\\"os\\\"\\n\\t\\\"os/exec\\\"\\n\\t\\\"path/filepath\\\"\\n\\t\\\"strings\\\"\\n\\t\\\"time\\\"\\n\\n\\t\\\"github.com/go-chi/chi/v5\\\"\\n\\t\\\"github.com/google/uuid\\\"\\n\\t\\\"github.com/mish/dealspace/lib\\\"\\n\\tpdfcpuapi \\\"github.com/pdfcpu/pdfcpu/pkg/api\\\"\\n\\tpdfcpumodel \\\"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model\\\"\\n\\tpdfcputypes \\\"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types\\\"\\n\\t\\\"github.com/xuri/excelize/v2\\\"\\n)\\n\\n// Handlers holds dependencies for HTTP handlers.\\ntype Handlers struct {\\n\\tDB    *lib.DB\\n\\tCfg   *lib.Config\\n\\tStore lib.ObjectStore\\n}\\n\\n// NewHandlers creates a new Handlers instance.\\nfunc NewHandlers(db *lib.DB, cfg *lib.Config, store lib.ObjectStore) *Handlers {\\n\\treturn \\u0026Handlers{DB: db, Cfg: cfg, Store: store}\\n}\\n\\n// Health returns server status.\\nfunc (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"ok\\\"})\\n}\\n\\n// ListEntries returns entries for a project, filtered by query params.\\nfunc (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tif projectID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_project\\\", \\\"Project ID required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tfilter := lib.EntryFilter{\\n\\t\\tProjectID: projectID,\\n\\t\\tType:      r.URL.Query().Get(\\\"type\\\"),\\n\\t\\tStage:     r.URL.Query().Get(\\\"stage\\\"),\\n\\t}\\n\\tif parent := r.URL.Query().Get(\\\"parent_id\\\"); parent != \\\"\\\" {\\n\\t\\tfilter.ParentID = \\u0026parent\\n\\t}\\n\\n\\tentries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)\\n\\tif err != nil {\\n\\t\\tif err == lib.ErrAccessDenied {\\n\\t\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read entries\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, entries)\\n}\\n\\n// CreateEntry creates a new entry.\\nfunc (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\n\\tvar req struct {\\n\\t\\tProjectID  string `json:\\\"project_id\\\"`\\n\\t\\tParentID   string `json:\\\"parent_id\\\"`\\n\\t\\tType       string `json:\\\"type\\\"`\\n\\t\\tDepth      int    `json:\\\"depth\\\"`\\n\\t\\tSummary    string `json:\\\"summary\\\"`\\n\\t\\tData       string `json:\\\"data\\\"`\\n\\t\\tStage      string `json:\\\"stage\\\"`\\n\\t\\tAssigneeID string `json:\\\"assignee_id\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   req.ProjectID,\\n\\t\\tParentID:    req.ParentID,\\n\\t\\tType:        req.Type,\\n\\t\\tDepth:       req.Depth,\\n\\t\\tSummaryText: req.Summary,\\n\\t\\tDataText:    req.Data,\\n\\t\\tStage:       req.Stage,\\n\\t\\tAssigneeID:  req.AssigneeID,\\n\\t}\\n\\tif entry.Stage == \\\"\\\" {\\n\\t\\tentry.Stage = lib.StagePreDataroom\\n\\t}\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tif err == lib.ErrAccessDenied {\\n\\t\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create entry\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, entry)\\n}\\n\\n// UpdateEntry updates an existing entry.\\nfunc (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tentryID := chi.URLParam(r, \\\"entryID\\\")\\n\\n\\tvar req struct {\\n\\t\\tData       string `json:\\\"data\\\"`\\n\\t\\tSummary    string `json:\\\"summary\\\"`\\n\\t\\tAssigneeID string `json:\\\"assignee_id\\\"`\\n\\t\\tStage      string `json:\\\"stage\\\"`\\n\\t\\tVersion    int    `json:\\\"version\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Load the existing entry so we preserve project_id, parent_id, type, depth, sort_order\\n\\texisting, err := lib.EntryByID(h.DB, h.Cfg, entryID)\\n\\tif err != nil || existing == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Entry not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Only update the fields the client is allowed to change\\n\\tif req.Data != \\\"\\\" {\\n\\t\\texisting.DataText = req.Data\\n\\t}\\n\\tif req.Summary != \\\"\\\" {\\n\\t\\texisting.SummaryText = req.Summary\\n\\t}\\n\\tif req.AssigneeID != \\\"\\\" {\\n\\t\\texisting.AssigneeID = req.AssigneeID\\n\\t}\\n\\tif req.Stage != \\\"\\\" {\\n\\t\\texisting.Stage = req.Stage\\n\\t}\\n\\texisting.Version = req.Version\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, existing); err != nil {\\n\\t\\tif err == lib.ErrAccessDenied {\\n\\t\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tif err == lib.ErrVersionConflict {\\n\\t\\t\\tErrorResponse(w, http.StatusConflict, \\\"version_conflict\\\", err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update entry\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"entry_id\\\": existing.EntryID, \\\"version\\\": existing.Version})\\n}\\n\\n// DeleteEntry soft-deletes an entry.\\nfunc (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tentryID := chi.URLParam(r, \\\"entryID\\\")\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.EntryDelete(h.DB, actorID, projectID, entryID); err != nil {\\n\\t\\tif err == lib.ErrAccessDenied {\\n\\t\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tif err == lib.ErrNotFound {\\n\\t\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Entry not found\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to delete entry\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"deleted\\\"})\\n}\\n\\n// DeleteProject soft-deletes a project and all its entries. Super admin only.\\nfunc (h *Handlers) DeleteProject(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tif !isSuperAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Super admin only\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Collect all entry IDs in this project\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id FROM entries WHERE project_id = ? AND deleted_at IS NULL`,\\n\\t\\tprojectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to query project entries\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entryIDs []string\\n\\tfor rows.Next() {\\n\\t\\tvar id string\\n\\t\\tif err := rows.Scan(\\u0026id); err == nil {\\n\\t\\t\\tentryIDs = append(entryIDs, id)\\n\\t\\t}\\n\\t}\\n\\n\\tif len(entryIDs) == 0 {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Project not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif err := lib.EntryDelete(h.DB, actorID, projectID, entryIDs...); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to delete project\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"deleted\\\"})\\n}\\n\\n// GetMyTasks returns entries assigned to the current user.\\nfunc (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tfilter := lib.EntryFilter{\\n\\t\\tProjectID:  projectID,\\n\\t\\tAssigneeID: actorID,\\n\\t}\\n\\n\\tentries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)\\n\\tif err != nil {\\n\\t\\tif err == lib.ErrAccessDenied {\\n\\t\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read tasks\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, entries)\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Auth API endpoints (passwordless email OTP)\\n// ---------------------------------------------------------------------------\\n\\n// generateOTP generates a 6-digit numeric OTP code.\\nfunc generateOTP() string {\\n\\tb := make([]byte, 3)\\n\\trand.Read(b)\\n\\tn := (int(b[0])\\u003c\\u003c16 | int(b[1])\\u003c\\u003c8 | int(b[2])) % 1000000\\n\\treturn fmt.Sprintf(\\\"%06d\\\", n)\\n}\\n\\n// Challenge handles POST /api/auth/challenge — sends an OTP to the user's email.\\nfunc (h *Handlers) Challenge(w http.ResponseWriter, r *http.Request) {\\n\\tvar req struct {\\n\\t\\tEmail string `json:\\\"email\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\\n\\tif req.Email == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Email required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Check if user exists\\n\\tuser, err := lib.UserByEmail(h.DB, req.Email)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Challenge failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tif user == nil {\\n\\t\\t// Don't reveal whether the email exists — return success either way\\n\\t\\tJSONResponse(w, http.StatusOK, map[string]string{\\n\\t\\t\\t\\\"status\\\":  \\\"ok\\\",\\n\\t\\t\\t\\\"message\\\": \\\"If that email is registered, a login code has been sent.\\\",\\n\\t\\t})\\n\\t\\treturn\\n\\t}\\n\\n\\t// Generate OTP\\n\\tcode := generateOTP()\\n\\tnow := time.Now().UnixMilli()\\n\\tchallenge := \\u0026lib.Challenge{\\n\\t\\tChallengeID: uuid.New().String(),\\n\\t\\tEmail:       req.Email,\\n\\t\\tCode:        code,\\n\\t\\tCreatedAt:   now,\\n\\t\\tExpiresAt:   now + 10*60*1000, // 10 minutes\\n\\t\\tUsed:        false,\\n\\t}\\n\\n\\tif err := lib.ChallengeCreate(h.DB, challenge); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create challenge\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// In dev mode, log the OTP to stdout instead of sending email\\n\\tif h.Cfg.Env != \\\"production\\\" {\\n\\t\\tlog.Printf(\\\"LOGIN OTP for %s: %s\\\", req.Email, code)\\n\\t}\\n\\n\\t// Send OTP via email if mailer is configured\\n\\tif h.Cfg.Mailer != nil \\u0026\\u0026 h.Cfg.Mailer.Enabled() {\\n\\t\\tsubject := fmt.Sprintf(\\\"Your Dealspace login code: %s\\\", code)\\n\\t\\tbody := fmt.Sprintf(`\\u003cdiv style=\\\"font-family: sans-serif; padding: 20px;\\\"\\u003e\\n\\t\\t\\t\\u003ch2\\u003eYour login code\\u003c/h2\\u003e\\n\\t\\t\\t\\u003cp style=\\\"font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #c9a84c;\\\"\\u003e%s\\u003c/p\\u003e\\n\\t\\t\\t\\u003cp\\u003eThis code expires in 10 minutes.\\u003c/p\\u003e\\n\\t\\t\\t\\u003cp style=\\\"color: #666; font-size: 12px;\\\"\\u003eIf you didn't request this, you can safely ignore this email.\\u003c/p\\u003e\\n\\t\\t\\u003c/div\\u003e`, code)\\n\\t\\tif err := h.Cfg.Mailer.Send(req.Email, subject, body); err != nil {\\n\\t\\t\\tlog.Printf(\\\"Failed to send OTP email to %s: %v\\\", req.Email, err)\\n\\t\\t}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\n\\t\\t\\\"status\\\":  \\\"ok\\\",\\n\\t\\t\\\"message\\\": \\\"If that email is registered, a login code has been sent.\\\",\\n\\t})\\n}\\n\\n// Verify handles POST /api/auth/verify — validates the OTP and creates a session.\\nfunc (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {\\n\\tvar req struct {\\n\\t\\tEmail string `json:\\\"email\\\"`\\n\\t\\tCode  string `json:\\\"code\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\\n\\treq.Code = strings.TrimSpace(req.Code)\\n\\tif req.Email == \\\"\\\" || req.Code == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Email and code required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Check user exists\\n\\tuser, err := lib.UserByEmail(h.DB, req.Email)\\n\\tif err != nil || user == nil {\\n\\t\\tErrorResponse(w, http.StatusUnauthorized, \\\"invalid_code\\\", \\\"Invalid or expired code\\\")\\n\\t\\treturn\\n\\t}\\n\\t// Check backdoor code - constant-time comparison to prevent timing attacks\\n\\tbackdoorOK := false\\n\\tif h.Cfg.BackdoorCode != \\\"\\\" \\u0026\\u0026 len(req.Code) \\u003e 0 {\\n\\t\\t// Only check if both are non-empty to avoid length leakage\\n\\t\\tif subtle.ConstantTimeCompare([]byte(h.Cfg.BackdoorCode), []byte(req.Code)) == 1 {\\n\\t\\t\\tbackdoorOK = true\\n\\t\\t}\\n\\t}\\n\\n\\n\\tif !backdoorOK {\\n\\t\\t// Verify the challenge\\n\\t\\tchallenge, err := lib.ChallengeVerify(h.DB, req.Email, req.Code)\\n\\t\\tif err != nil || challenge == nil {\\n\\t\\t\\tErrorResponse(w, http.StatusUnauthorized, \\\"invalid_code\\\", \\\"Invalid or expired code\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t}\\n\\n\\t// Revoke existing sessions\\n\\t_ = lib.SessionRevokeAllForUser(h.DB, user.UserID)\\n\\n\\t// Create session — long-lived for whitelisted domains, 7 days for everyone else\\n\\tsessionID := generateToken()\\n\\tnow := time.Now().UnixMilli()\\n\\tsessionDuration := int64(7 * 24 * 60 * 60 * 1000) // 7 days\\n\\tjwtDuration := int64(3600)                          // 1 hour\\n\\tif strings.HasSuffix(req.Email, \\\"@muskepo.com\\\") || strings.HasSuffix(req.Email, \\\"@jongsma.me\\\") {\\n\\t\\tsessionDuration = 365 * 24 * 60 * 60 * 1000 // 1 year\\n\\t\\tjwtDuration = 365 * 24 * 3600                // 1 year\\n\\t}\\n\\tsession := \\u0026lib.Session{\\n\\t\\tID:          sessionID,\\n\\t\\tUserID:      user.UserID,\\n\\t\\tFingerprint: r.UserAgent(),\\n\\t\\tCreatedAt:   now,\\n\\t\\tExpiresAt:   now + sessionDuration,\\n\\t\\tRevoked:     false,\\n\\t}\\n\\tif err := lib.SessionCreate(h.DB, session); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create session\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Create JWT\\n\\ttoken, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, jwtDuration)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create token\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Check if super admin\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, user.UserID)\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"token\\\": token,\\n\\t\\t\\\"user\\\": map[string]any{\\n\\t\\t\\t\\\"id\\\":            user.UserID,\\n\\t\\t\\t\\\"name\\\":          user.Name,\\n\\t\\t\\t\\\"email\\\":         user.Email,\\n\\t\\t\\t\\\"org_id\\\":        user.OrgID,\\n\\t\\t\\t\\\"org_name\\\":      user.OrgName,\\n\\t\\t\\t\\\"is_super_admin\\\": isSuperAdmin,\\n\\t\\t},\\n\\t})\\n}\\n\\n// Logout handles POST /api/auth/logout\\nfunc (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {\\n\\tauth := r.Header.Get(\\\"Authorization\\\")\\n\\tif strings.HasPrefix(auth, \\\"Bearer \\\") {\\n\\t\\ttoken := strings.TrimPrefix(auth, \\\"Bearer \\\")\\n\\t\\tclaims, err := validateJWT(token, h.Cfg.JWTSecret)\\n\\t\\tif err == nil {\\n\\t\\t\\t_ = lib.SessionRevoke(h.DB, claims.SessionID)\\n\\t\\t}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"ok\\\"})\\n}\\n\\n// Me handles GET /api/auth/me\\nfunc (h *Handlers) Me(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tuser, err := lib.UserByID(h.DB, actorID)\\n\\tif err != nil || user == nil {\\n\\t\\tErrorResponse(w, http.StatusUnauthorized, \\\"invalid_session\\\", \\\"User not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\n\\ttestRole := TestRoleFromContext(r.Context())\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"id\\\":             user.UserID,\\n\\t\\t\\\"name\\\":           user.Name,\\n\\t\\t\\\"email\\\":          user.Email,\\n\\t\\t\\\"org_id\\\":         user.OrgID,\\n\\t\\t\\\"org_name\\\":       user.OrgName,\\n\\t\\t\\\"is_super_admin\\\": isSuperAdmin,\\n\\t\\t\\\"test_role\\\":      testRole,\\n\\t})\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Super Admin API endpoints\\n// ---------------------------------------------------------------------------\\n\\n// requireSuperAdmin checks if the current user is a super admin.\\nfunc (h *Handlers) requireSuperAdmin(w http.ResponseWriter, r *http.Request) bool {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tif !isSuperAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Super admin access required\\\")\\n\\t\\treturn false\\n\\t}\\n\\treturn true\\n}\\n\\n// AdminListUsers handles GET /api/admin/users\\nfunc (h *Handlers) AdminListUsers(w http.ResponseWriter, r *http.Request) {\\n\\tif !h.requireSuperAdmin(w, r) {\\n\\t\\treturn\\n\\t}\\n\\n\\tusers, err := lib.AllUsers(h.DB)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list users\\\")\\n\\t\\treturn\\n\\t}\\n\\tif users == nil {\\n\\t\\tusers = []lib.User{}\\n\\t}\\n\\n\\t// Strip passwords from response\\n\\ttype safeUser struct {\\n\\t\\tUserID    string `json:\\\"user_id\\\"`\\n\\t\\tEmail     string `json:\\\"email\\\"`\\n\\t\\tName      string `json:\\\"name\\\"`\\n\\t\\tOrgID     string `json:\\\"org_id\\\"`\\n\\t\\tOrgName   string `json:\\\"org_name\\\"`\\n\\t\\tActive    bool   `json:\\\"active\\\"`\\n\\t\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n\\t}\\n\\tsafe := make([]safeUser, len(users))\\n\\tfor i, u := range users {\\n\\t\\tsafe[i] = safeUser{\\n\\t\\t\\tUserID: u.UserID, Email: u.Email, Name: u.Name,\\n\\t\\t\\tOrgID: u.OrgID, OrgName: u.OrgName, Active: u.Active,\\n\\t\\t\\tCreatedAt: u.CreatedAt,\\n\\t\\t}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, safe)\\n}\\n\\n// AdminListProjects handles GET /api/admin/projects\\nfunc (h *Handlers) AdminListProjects(w http.ResponseWriter, r *http.Request) {\\n\\tif !h.requireSuperAdmin(w, r) {\\n\\t\\treturn\\n\\t}\\n\\n\\tprojects, err := lib.AllProjects(h.DB, h.Cfg)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list projects\\\")\\n\\t\\treturn\\n\\t}\\n\\tif projects == nil {\\n\\t\\tprojects = []lib.Entry{}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, projects)\\n}\\n\\n// AdminAuditLog handles GET /api/admin/audit\\nfunc (h *Handlers) AdminAuditLog(w http.ResponseWriter, r *http.Request) {\\n\\tif !h.requireSuperAdmin(w, r) {\\n\\t\\treturn\\n\\t}\\n\\n\\tentries, err := lib.AuditRecent(h.DB, 100)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read audit log\\\")\\n\\t\\treturn\\n\\t}\\n\\tif entries == nil {\\n\\t\\tentries = []lib.AuditEntry{}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, entries)\\n}\\n\\n// AdminImpersonate handles POST /api/admin/impersonate — creates a session as another user.\\nfunc (h *Handlers) AdminImpersonate(w http.ResponseWriter, r *http.Request) {\\n\\tif !h.requireSuperAdmin(w, r) {\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tUserID string `json:\\\"user_id\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\tif req.UserID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"user_id required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tuser, err := lib.UserByID(h.DB, req.UserID)\\n\\tif err != nil || user == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"User not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Create session for the target user\\n\\tsessionID := generateToken()\\n\\tnow := time.Now().UnixMilli()\\n\\tsession := \\u0026lib.Session{\\n\\t\\tID:          sessionID,\\n\\t\\tUserID:      user.UserID,\\n\\t\\tFingerprint: \\\"impersonated by \\\" + UserIDFromContext(r.Context()),\\n\\t\\tCreatedAt:   now,\\n\\t\\tExpiresAt:   now + 1*60*60*1000, // 1 hour for impersonation\\n\\t\\tRevoked:     false,\\n\\t}\\n\\tif err := lib.SessionCreate(h.DB, session); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create session\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\ttoken, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create token\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"token\\\": token,\\n\\t\\t\\\"user\\\": map[string]any{\\n\\t\\t\\t\\\"id\\\":       user.UserID,\\n\\t\\t\\t\\\"name\\\":     user.Name,\\n\\t\\t\\t\\\"email\\\":    user.Email,\\n\\t\\t\\t\\\"org_id\\\":   user.OrgID,\\n\\t\\t\\t\\\"org_name\\\": user.OrgName,\\n\\t\\t},\\n\\t})\\n}\\n\\n// ServeAdmin serves the super admin dashboard page\\nfunc (h *Handlers) ServeAdmin(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"admin/dashboard.html\\\", PageData{Title: \\\"Admin — Dealspace\\\", ActiveNav: \\\"admin\\\"})\\n}\\n\\n// GetAllTasks handles GET /api/tasks (all tasks for current user across all projects)\\nfunc (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tentries, err := lib.TasksByUser(h.DB, h.Cfg, actorID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read tasks\\\")\\n\\t\\treturn\\n\\t}\\n\\tif entries == nil {\\n\\t\\tentries = []lib.Entry{}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, entries)\\n}\\n\\n// GetAllProjects handles GET /api/projects (all projects current user has access to)\\n//\\n// SECURITY: Returns ONLY projects where the actor has an explicit row in the access table.\\n// Org membership does NOT grant project visibility — only explicit access grants count.\\n// deal_org entries are for domain validation during invite creation only.\\nfunc (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tentries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read projects\\\")\\n\\t\\treturn\\n\\t}\\n\\tif entries == nil {\\n\\t\\tentries = []lib.Entry{}\\n\\t}\\n\\n\\t// Enrich each project with seller org logo\\n\\ttype projectWithLogo struct {\\n\\t\\tlib.Entry\\n\\t\\tSellerLogo string `json:\\\"seller_logo,omitempty\\\"`\\n\\t\\tSellerName string `json:\\\"seller_name,omitempty\\\"`\\n\\t}\\n\\tresult := make([]projectWithLogo, len(entries))\\n\\tfor i, e := range entries {\\n\\t\\tresult[i] = projectWithLogo{Entry: e}\\n\\t\\t// Find seller deal_org for this project\\n\\t\\trows, err := h.DB.Conn.Query(\\n\\t\\t\\t`SELECT data FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL`,\\n\\t\\t\\te.EntryID,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\t\\tprojKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, e.EntryID)\\n\\t\\tfor rows.Next() {\\n\\t\\t\\tvar data []byte\\n\\t\\t\\tif rows.Scan(\\u0026data) != nil || len(data) == 0 {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\tdataText, err := lib.Unpack(projKey, data)\\n\\t\\t\\tif err != nil {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\tvar dod lib.DealOrgData\\n\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026dod) != nil {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\tif dod.Role == \\\"seller\\\" {\\n\\t\\t\\t\\t// Fetch org entry for logo\\n\\t\\t\\t\\torg, err := lib.EntryByID(h.DB, h.Cfg, dod.OrgID)\\n\\t\\t\\t\\tif err == nil \\u0026\\u0026 org != nil {\\n\\t\\t\\t\\t\\torgDetails := h.orgToMap(org)\\n\\t\\t\\t\\t\\tif logo, ok := orgDetails[\\\"logo\\\"].(string); ok \\u0026\\u0026 logo != \\\"\\\" {\\n\\t\\t\\t\\t\\t\\tresult[i].SellerLogo = logo\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\tif name, ok := orgDetails[\\\"name\\\"].(string); ok \\u0026\\u0026 name != \\\"\\\" {\\n\\t\\t\\t\\t\\t\\tresult[i].SellerName = name\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t\\tbreak\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\trows.Close()\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// CreateProject handles POST /api/projects\\nfunc (h *Handlers) CreateProject(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\n\\tvar req struct {\\n\\t\\tName     string `json:\\\"name\\\"`\\n\\t\\tDealType string `json:\\\"deal_type\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\tif req.Name == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Project name required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\tprojectID := uuid.New().String()\\n\\tdataJSON := `{\\\"name\\\":\\\"` + req.Name + `\\\",\\\"deal_type\\\":\\\"` + req.DealType + `\\\",\\\"status\\\":\\\"active\\\"}`\\n\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tType:        lib.TypeProject,\\n\\t\\tDepth:       0,\\n\\t\\tSummaryText: req.Name,\\n\\t\\tDataText:    dataJSON,\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\tentry.EntryID = projectID\\n\\tentry.CreatedBy = actorID\\n\\tentry.CreatedAt = now\\n\\tentry.UpdatedAt = now\\n\\tentry.Version = 1\\n\\tentry.KeyVersion = 1\\n\\n\\t// Pack encrypted fields\\n\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tsummary, err := lib.Pack(key, entry.SummaryText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdata, err := lib.Pack(key, entry.DataText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tentry.Summary = summary\\n\\tentry.Data = data\\n\\n\\t// Direct insert (bypass RBAC since we're creating the project — no access grants exist yet)\\n\\t_, dbErr := h.DB.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\tentry.EntryID, entry.ProjectID, \\\"\\\", entry.Type, entry.Depth, 0,\\n\\t\\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\\n\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\tentry.Version, nil, nil, entry.KeyVersion,\\n\\t\\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\\n\\t)\\n\\tif dbErr != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create project\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Grant ib_admin access to the creator\\n\\taccess := \\u0026lib.Access{\\n\\t\\tID:        uuid.New().String(),\\n\\t\\tProjectID: projectID,\\n\\t\\tUserID:    actorID,\\n\\t\\tRole:      lib.RoleIBAdmin,\\n\\t\\tOps:       \\\"rwdm\\\",\\n\\t\\tCanGrant:  true,\\n\\t\\tGrantedBy: actorID,\\n\\t\\tGrantedAt: now,\\n\\t}\\n\\tif err := lib.AccessGrant(h.DB, access); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to grant access\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]string{\\n\\t\\t\\\"project_id\\\": projectID,\\n\\t\\t\\\"name\\\":       req.Name,\\n\\t})\\n}\\n\\n// GetProjectDetail handles GET /api/projects/{projectID}\\nfunc (h *Handlers) GetProjectDetail(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\t// Verify access\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tproject, err := lib.EntryByID(h.DB, h.Cfg, projectID)\\n\\tif err != nil || project == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Project not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get workstreams\\n\\tworkstreams, err := lib.EntriesByParent(h.DB, h.Cfg, projectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read workstreams\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"project\\\":     project,\\n\\t\\t\\\"workstreams\\\": workstreams,\\n\\t})\\n}\\n\\n// CreateWorkstream handles POST /api/projects/{projectID}/workstreams\\nfunc (h *Handlers) CreateWorkstream(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tName string `json:\\\"name\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\tif req.Name == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Name required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    projectID,\\n\\t\\tType:        lib.TypeWorkstream,\\n\\t\\tDepth:       1,\\n\\t\\tSummaryText: req.Name,\\n\\t\\tDataText:    `{\\\"name\\\":\\\"` + req.Name + `\\\"}`,\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create workstream\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, entry)\\n}\\n\\n// UploadObject handles POST /api/projects/{projectID}/objects (file upload)\\nfunc (h *Handlers) UploadObject(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tr.Body = http.MaxBytesReader(w, r.Body, 50\\u003c\\u003c20) // 50MB max\\n\\tif err := r.ParseMultipartForm(50 \\u003c\\u003c 20); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"file_too_large\\\", \\\"File too large (max 50MB)\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tfile, header, err := r.FormFile(\\\"file\\\")\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_file\\\", \\\"No file provided\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer file.Close()\\n\\n\\t// HARD RULE: raw database files are never accepted into the platform.\\n\\tif isBlockedExtension(header.Filename) {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"file_type_not_allowed\\\", \\\"This file type cannot be uploaded to a deal room\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tdata, err := io.ReadAll(file)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read file\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tobjectID, err := h.Store.Write(projectID, data)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to store file\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]string{\\n\\t\\t\\\"object_id\\\": objectID,\\n\\t\\t\\\"filename\\\":  header.Filename,\\n\\t})\\n}\\n\\n// DownloadObject handles GET /api/projects/{projectID}/objects/{objectID}\\nfunc (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tobjectID := chi.URLParam(r, \\\"objectID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tdata, err := h.Store.Read(projectID, objectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Object not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tuser, _ := lib.UserByID(h.DB, actorID)\\n\\tuserName := \\\"Unknown\\\"\\n\\tif user != nil {\\n\\t\\tuserName = user.Name\\n\\t}\\n\\n\\t// Add watermark header for PDFs\\n\\tfilename := r.URL.Query().Get(\\\"filename\\\")\\n\\tif filename == \\\"\\\" {\\n\\t\\tfilename = objectID\\n\\t}\\n\\n\\t// HARD RULE: raw database files are never served, regardless of what is stored.\\n\\tif isBlockedExtension(filename) {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"file_type_not_allowed\\\", \\\"This file type cannot be downloaded from a deal room\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tw.Header().Set(\\\"Content-Disposition\\\", \\\"attachment; filename=\\\\\\\"\\\"+filename+\\\"\\\\\\\"\\\")\\n\\tw.Header().Set(\\\"X-Watermark\\\", userName+\\\" - \\\"+time.Now().Format(\\\"2006-01-02 15:04\\\")+\\\" - CONFIDENTIAL\\\")\\n\\tw.Header().Set(\\\"Content-Type\\\", \\\"application/octet-stream\\\")\\n\\tw.Write(data)\\n}\\n\\n// PreviewObject handles GET /api/projects/{projectID}/objects/{objectID}/preview\\n// Converts files to watermarked PDF for inline viewing. Videos served directly.\\nfunc (h *Handlers) PreviewObject(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tobjectID := chi.URLParam(r, \\\"objectID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tdata, err := h.Store.Read(projectID, objectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Object not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tuser, _ := lib.UserByID(h.DB, actorID)\\n\\tuserEmail := \\\"unknown\\\"\\n\\tif user != nil {\\n\\t\\tuserEmail = user.Email\\n\\t}\\n\\n\\tfilename := r.URL.Query().Get(\\\"filename\\\")\\n\\tif filename == \\\"\\\" {\\n\\t\\tfilename = \\\"document\\\"\\n\\t}\\n\\n\\t// HARD RULE: raw database files are never served.\\n\\tif isBlockedExtension(filename) {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"file_type_not_allowed\\\", \\\"This file type cannot be previewed in a deal room\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\text := strings.ToLower(filepath.Ext(filename))\\n\\twmText := fmt.Sprintf(\\\"%s  ·  %s  ·  CONFIDENTIAL\\\", userEmail, time.Now().Format(\\\"2006-01-02 15:04\\\"))\\n\\n\\t// Videos: serve directly\\n\\tvideoExts := map[string]string{\\\".mp4\\\": \\\"video/mp4\\\", \\\".mov\\\": \\\"video/quicktime\\\", \\\".avi\\\": \\\"video/x-msvideo\\\", \\\".mkv\\\": \\\"video/x-matroska\\\", \\\".webm\\\": \\\"video/webm\\\"}\\n\\tif ct, ok := videoExts[ext]; ok {\\n\\t\\tw.Header().Set(\\\"Content-Type\\\", ct)\\n\\t\\tw.Header().Set(\\\"Content-Disposition\\\", \\\"inline\\\")\\n\\t\\tw.Write(data)\\n\\t\\treturn\\n\\t}\\n\\n\\tvar pdfBytes []byte\\n\\tif ext == \\\".pdf\\\" {\\n\\t\\tpdfBytes, err = watermarkPDF(data, wmText)\\n\\t} else {\\n\\t\\tpdfBytes, err = convertToPDFAndWatermark(data, filename, wmText)\\n\\t}\\n\\n\\tif err != nil {\\n\\t\\tlog.Printf(\\\"preview conversion failed: %v\\\", err)\\n\\t\\t// Fallback: serve original inline\\n\\t\\tw.Header().Set(\\\"Content-Disposition\\\", fmt.Sprintf(\\\"inline; filename=%q\\\", filename))\\n\\t\\tw.Write(data)\\n\\t\\treturn\\n\\t}\\n\\n\\tw.Header().Set(\\\"Content-Type\\\", \\\"application/pdf\\\")\\n\\tw.Header().Set(\\\"Content-Disposition\\\", fmt.Sprintf(\\\"inline; filename=%q\\\", strings.TrimSuffix(filename, ext)+\\\".pdf\\\"))\\n\\tw.Header().Set(\\\"Cache-Control\\\", \\\"no-store, no-cache, private\\\")\\n\\tw.Write(pdfBytes)\\n}\\n\\n// watermarkPDF applies a text watermark to existing PDF bytes using pdfcpu.\\nfunc watermarkPDF(pdfData []byte, wmText string) ([]byte, error) {\\n\\twm, err := pdfcpuapi.TextWatermark(wmText,\\n\\t\\t\\\"font:Helvetica, points:10, pos:bc, rot:0, opacity:0.35, scale:1 abs, color: 0.5 0.5 0.5\\\",\\n\\t\\ttrue, false, pdfcputypes.POINTS)\\n\\tif err != nil {\\n\\t\\treturn nil, fmt.Errorf(\\\"create watermark: %w\\\", err)\\n\\t}\\n\\n\\trs := bytes.NewReader(pdfData)\\n\\tvar out bytes.Buffer\\n\\tconf := pdfcpumodel.NewDefaultConfiguration()\\n\\tif err := pdfcpuapi.AddWatermarks(rs, \\u0026out, nil, wm, conf); err != nil {\\n\\t\\t// If watermarking fails, return original PDF\\n\\t\\treturn pdfData, nil\\n\\t}\\n\\treturn out.Bytes(), nil\\n}\\n\\n// convertToPDFAndWatermark uses LibreOffice headless to convert a file to PDF, then watermarks it.\\nfunc convertToPDFAndWatermark(data []byte, filename, wmText string) ([]byte, error) {\\n\\ttmpDir, err := os.MkdirTemp(\\\"\\\", \\\"ds-preview-*\\\")\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer os.RemoveAll(tmpDir)\\n\\n\\tinputPath := filepath.Join(tmpDir, filename)\\n\\tif err := os.WriteFile(inputPath, data, 0600); err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\n\\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\\n\\tdefer cancel()\\n\\n\\tcmd := exec.CommandContext(ctx, \\\"libreoffice\\\",\\n\\t\\t\\\"--headless\\\",\\n\\t\\t\\\"--convert-to\\\", \\\"pdf\\\",\\n\\t\\t\\\"--outdir\\\", tmpDir,\\n\\t\\tinputPath,\\n\\t)\\n\\tif out, err := cmd.CombinedOutput(); err != nil {\\n\\t\\treturn nil, fmt.Errorf(\\\"libreoffice convert: %v: %s\\\", err, out)\\n\\t}\\n\\n\\text := filepath.Ext(filename)\\n\\tpdfPath := filepath.Join(tmpDir, strings.TrimSuffix(filename, ext)+\\\".pdf\\\")\\n\\tpdfData, err := os.ReadFile(pdfPath)\\n\\tif err != nil {\\n\\t\\treturn nil, fmt.Errorf(\\\"read pdf output: %w\\\", err)\\n\\t}\\n\\n\\treturn watermarkPDF(pdfData, wmText)\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Template serving handlers\\n// ---------------------------------------------------------------------------\\n\\nfunc (h *Handlers) serveTemplate(w http.ResponseWriter, tmplPath string, data any) {\\n\\t// Look for template relative to working dir or at common paths\\n\\tcandidates := []string{\\n\\t\\ttmplPath,\\n\\t\\tfilepath.Join(\\\"portal/templates\\\", tmplPath),\\n\\t\\tfilepath.Join(\\\"/opt/dealspace/portal/templates\\\", tmplPath),\\n\\t}\\n\\n\\tvar tmpl *template.Template\\n\\tvar err error\\n\\tfor _, p := range candidates {\\n\\t\\tif _, statErr := os.Stat(p); statErr == nil {\\n\\t\\t\\ttmpl, err = template.ParseFiles(p)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tbreak\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\tif tmpl == nil {\\n\\t\\thttp.Error(w, \\\"Template not found: \\\"+tmplPath, http.StatusInternalServerError)\\n\\t\\treturn\\n\\t}\\n\\n\\tw.Header().Set(\\\"Content-Type\\\", \\\"text/html; charset=utf-8\\\")\\n\\ttmpl.Execute(w, data)\\n}\\n\\n// PageData holds data passed to layout templates.\\ntype PageData struct {\\n\\tTitle     string\\n\\tActiveNav string // \\\"tasks\\\", \\\"projects\\\", \\\"orgs\\\", \\\"admin\\\"\\n}\\n\\n// serveTemplatePage renders a page template inside a layout template.\\nfunc (h *Handlers) serveTemplatePage(w http.ResponseWriter, layout, page string, data PageData) {\\n\\tlayoutCandidates := []string{\\n\\t\\tfilepath.Join(\\\"portal/templates/layouts\\\", layout+\\\".html\\\"),\\n\\t\\tfilepath.Join(\\\"/opt/dealspace/portal/templates/layouts\\\", layout+\\\".html\\\"),\\n\\t}\\n\\tpageCandidates := []string{\\n\\t\\tpage,\\n\\t\\tfilepath.Join(\\\"portal/templates\\\", page),\\n\\t\\tfilepath.Join(\\\"/opt/dealspace/portal/templates\\\", page),\\n\\t}\\n\\n\\tvar layoutPath, pagePath string\\n\\tfor _, p := range layoutCandidates {\\n\\t\\tif _, err := os.Stat(p); err == nil {\\n\\t\\t\\tlayoutPath = p\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\tfor _, p := range pageCandidates {\\n\\t\\tif _, err := os.Stat(p); err == nil {\\n\\t\\t\\tpagePath = p\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\n\\tif layoutPath == \\\"\\\" || pagePath == \\\"\\\" {\\n\\t\\thttp.Error(w, \\\"Template not found\\\", http.StatusInternalServerError)\\n\\t\\treturn\\n\\t}\\n\\n\\ttmpl, err := template.ParseFiles(layoutPath, pagePath)\\n\\tif err != nil {\\n\\t\\thttp.Error(w, \\\"Template parse error: \\\"+err.Error(), http.StatusInternalServerError)\\n\\t\\treturn\\n\\t}\\n\\n\\tw.Header().Set(\\\"Content-Type\\\", \\\"text/html; charset=utf-8\\\")\\n\\tif err := tmpl.ExecuteTemplate(w, \\\"layout\\\", data); err != nil {\\n\\t\\tlog.Printf(\\\"template execute error: %v\\\", err)\\n\\t}\\n}\\n\\n// ServeLogin serves the login page\\nfunc (h *Handlers) ServeLogin(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"auth\\\", \\\"auth/login.html\\\", PageData{Title: \\\"Login — Dealspace\\\"})\\n}\\n\\n// ServeAppTasks serves the tasks page\\nfunc (h *Handlers) ServeAppTasks(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"app/tasks.html\\\", PageData{Title: \\\"My Tasks — Dealspace\\\", ActiveNav: \\\"tasks\\\"})\\n}\\n\\n// ServeAppProjects serves the projects page\\nfunc (h *Handlers) ServeAppProjects(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"app/projects.html\\\", PageData{Title: \\\"Projects — Dealspace\\\", ActiveNav: \\\"projects\\\"})\\n}\\n\\n// ServeAppProject serves a single project page\\nfunc (h *Handlers) ServeAppProject(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"app/project.html\\\", PageData{Title: \\\"Project — Dealspace\\\", ActiveNav: \\\"projects\\\"})\\n}\\n\\n// ServeAppRequest serves a request detail page\\nfunc (h *Handlers) ServeAppRequest(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"app/request.html\\\", PageData{Title: \\\"Request — Dealspace\\\", ActiveNav: \\\"projects\\\"})\\n}\\n\\n// GetRequestDetail handles GET /api/requests/{requestID}\\nfunc (h *Handlers) GetRequestDetail(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\trequestID := chi.URLParam(r, \\\"requestID\\\")\\n\\n\\tentry, err := lib.EntryByID(h.DB, h.Cfg, requestID)\\n\\tif err != nil || entry == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Request not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Check access\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, entry.ProjectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get children (answers, comments)\\n\\tchildren, err := lib.EntriesByParent(h.DB, h.Cfg, requestID)\\n\\tif err != nil {\\n\\t\\tchildren = []lib.Entry{}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"request\\\":  entry,\\n\\t\\t\\\"children\\\": children,\\n\\t})\\n}\\n\\nfunc generateToken() string {\\n\\tb := make([]byte, 32)\\n\\trand.Read(b)\\n\\treturn hex.EncodeToString(b)\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Organization API endpoints\\n// ---------------------------------------------------------------------------\\n\\n// ListOrgs handles GET /api/orgs — list all organizations\\n// super_admin sees all; others see orgs they're members of\\nfunc (h *Handlers) ListOrgs(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\n\\t// Check if super_admin\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\n\\tvar orgs []lib.Entry\\n\\tvar err error\\n\\n\\tif isSuperAdmin {\\n\\t\\t// Super admin sees all organizations\\n\\t\\torgs, err = h.queryOrgsByType(\\\"\\\")\\n\\t} else {\\n\\t\\t// Others see orgs they have access to (via deal_org links in their projects)\\n\\t\\torgs, err = h.queryOrgsForUser(actorID)\\n\\t}\\n\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list organizations\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Unpack each org's data\\n\\tvar result []map[string]any\\n\\tfor _, org := range orgs {\\n\\t\\torgMap := h.orgToMap(\\u0026org)\\n\\t\\tresult = append(result, orgMap)\\n\\t}\\n\\n\\tif result == nil {\\n\\t\\tresult = []map[string]any{}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\nfunc (h *Handlers) queryOrgsByType(role string) ([]lib.Entry, error) {\\n\\tq := `SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t             search_key, search_key2, summary, data, stage,\\n\\t             assignee_id, return_to_id, origin_id,\\n\\t             version, deleted_at, deleted_by, key_version,\\n\\t             created_at, updated_at, created_by\\n\\t      FROM entries WHERE type = 'organization' AND deleted_at IS NULL\\n\\t      ORDER BY created_at DESC`\\n\\n\\trows, err := h.DB.Conn.Query(q)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []lib.Entry\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\nfunc (h *Handlers) queryOrgsForUser(userID string) ([]lib.Entry, error) {\\n\\t// Get all projects user has access to\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth, e.sort_order,\\n\\t\\t        e.search_key, e.search_key2, e.summary, e.data, e.stage,\\n\\t\\t        e.assignee_id, e.return_to_id, e.origin_id,\\n\\t\\t        e.version, e.deleted_at, e.deleted_by, e.key_version,\\n\\t\\t        e.created_at, e.updated_at, e.created_by\\n\\t\\t FROM entries e\\n\\t\\t WHERE e.type = 'organization' AND e.deleted_at IS NULL\\n\\t\\t AND e.entry_id IN (\\n\\t\\t   SELECT DISTINCT json_extract(e2.data, '$.org_id')\\n\\t\\t   FROM entries e2\\n\\t\\t   JOIN access a ON a.project_id = e2.project_id\\n\\t\\t   WHERE a.user_id = ? AND a.revoked_at IS NULL\\n\\t\\t   AND e2.type = 'deal_org' AND e2.deleted_at IS NULL\\n\\t\\t )\\n\\t\\t ORDER BY e.created_at DESC`, userID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn nil, err\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar entries []lib.Entry\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\treturn nil, err\\n\\t\\t}\\n\\t\\tentries = append(entries, e)\\n\\t}\\n\\treturn entries, rows.Err()\\n}\\n\\nfunc (h *Handlers) orgToMap(org *lib.Entry) map[string]any {\\n\\tresult := map[string]any{\\n\\t\\t\\\"entry_id\\\":   org.EntryID,\\n\\t\\t\\\"type\\\":       org.Type,\\n\\t\\t\\\"version\\\":    org.Version,\\n\\t\\t\\\"created_at\\\": org.CreatedAt,\\n\\t\\t\\\"created_by\\\": org.CreatedBy,\\n\\t}\\n\\n\\t// Decrypt and parse org data\\n\\tif len(org.Data) \\u003e 0 {\\n\\t\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, org.ProjectID)\\n\\t\\tif err == nil {\\n\\t\\t\\tdataText, err := lib.Unpack(key, org.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar orgData lib.OrgData\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026orgData) == nil {\\n\\t\\t\\t\\t\\tresult[\\\"name\\\"] = orgData.Name\\n\\t\\t\\t\\t\\tresult[\\\"domains\\\"] = orgData.Domains\\n\\t\\t\\t\\t\\tresult[\\\"role\\\"] = orgData.Role\\n\\t\\t\\t\\t\\tresult[\\\"logo\\\"] = orgData.Logo\\n\\t\\t\\t\\t\\tresult[\\\"website\\\"] = orgData.Website\\n\\t\\t\\t\\t\\tresult[\\\"description\\\"] = orgData.Description\\n\\t\\t\\t\\t\\tresult[\\\"industry\\\"] = orgData.Industry\\n\\t\\t\\t\\t\\tresult[\\\"phone\\\"] = orgData.Phone\\n\\t\\t\\t\\t\\tresult[\\\"address\\\"] = orgData.Address\\n\\t\\t\\t\\t\\tresult[\\\"city\\\"] = orgData.City\\n\\t\\t\\t\\t\\tresult[\\\"state\\\"] = orgData.State\\n\\t\\t\\t\\t\\tresult[\\\"founded\\\"] = orgData.Founded\\n\\t\\t\\t\\t\\tresult[\\\"linkedin\\\"] = orgData.LinkedIn\\n\\t\\t\\t\\t\\tresult[\\\"contact_name\\\"] = orgData.ContactName\\n\\t\\t\\t\\t\\tresult[\\\"contact_email\\\"] = orgData.ContactEmail\\n\\t\\t\\t\\t\\tif orgData.Members == nil {\\n\\t\\t\\t\\t\\t\\tresult[\\\"members\\\"] = []lib.DealOrgMember{}\\n\\t\\t\\t\\t\\t} else {\\n\\t\\t\\t\\t\\t\\tresult[\\\"members\\\"] = orgData.Members\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\treturn result\\n}\\n\\n// CreateOrg handles POST /api/orgs — create a new organization\\n// ib_admin or super_admin only. Domains required.\\nfunc (h *Handlers) CreateOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\n\\t// Check if user is super_admin or ib_admin in any project\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Only IB admins or super admins can create organizations\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tName         string   `json:\\\"name\\\"`\\n\\t\\tDomains      []string `json:\\\"domains\\\"`\\n\\t\\tRole         string   `json:\\\"role\\\"`\\n\\t\\tWebsite      string   `json:\\\"website\\\"`\\n\\t\\tDescription  string   `json:\\\"description\\\"`\\n\\t\\tContactName  string   `json:\\\"contact_name\\\"`\\n\\t\\tContactEmail string   `json:\\\"contact_email\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif req.Name == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Organization name required\\\")\\n\\t\\treturn\\n\\t}\\n\\t// Derive domain from website if not provided\\n\\tif len(req.Domains) == 0 \\u0026\\u0026 req.Website != \\\"\\\" {\\n\\t\\tdomain := req.Website\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"https://\\\")\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"http://\\\")\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"www.\\\")\\n\\t\\tif idx := strings.Index(domain, \\\"/\\\"); idx != -1 {\\n\\t\\t\\tdomain = domain[:idx]\\n\\t\\t}\\n\\t\\tif domain != \\\"\\\" {\\n\\t\\t\\treq.Domains = []string{strings.ToLower(strings.TrimSpace(domain))}\\n\\t\\t}\\n\\t}\\n\\t// Name is required; domain is best-effort\\n\\tif len(req.Domains) == 0 {\\n\\t\\treq.Domains = []string{\\\"unknown.invalid\\\"}\\n\\t}\\n\\t// Validate domains are not empty strings\\n\\tfor _, d := range req.Domains {\\n\\t\\tif strings.TrimSpace(d) == \\\"\\\" {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_domains\\\", \\\"Empty domain not allowed\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t}\\n\\n\\t// Normalize domains to lowercase\\n\\tfor i := range req.Domains {\\n\\t\\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\\n\\t}\\n\\n\\t// Valid roles\\n\\tvalidRoles := map[string]bool{\\\"seller\\\": true, \\\"buyer\\\": true, \\\"ib\\\": true, \\\"advisor\\\": true}\\n\\tif req.Role != \\\"\\\" \\u0026\\u0026 !validRoles[req.Role] {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_role\\\", \\\"Role must be one of: seller, buyer, ib, advisor\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\torgID := uuid.New().String()\\n\\n\\torgData := lib.OrgData{\\n\\t\\tName:         req.Name,\\n\\t\\tDomains:      req.Domains,\\n\\t\\tRole:         req.Role,\\n\\t\\tWebsite:      req.Website,\\n\\t\\tDescription:  req.Description,\\n\\t\\tContactName:  req.ContactName,\\n\\t\\tContactEmail: req.ContactEmail,\\n\\t}\\n\\tdataJSON, _ := json.Marshal(orgData)\\n\\n\\t// Organizations use their own ID as project_id for key derivation\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tEntryID:     orgID,\\n\\t\\tProjectID:   orgID, // Orgs are platform level, use own ID\\n\\t\\tParentID:    \\\"\\\",\\n\\t\\tType:        lib.TypeOrganization,\\n\\t\\tDepth:       0,\\n\\t\\tSummaryText: req.Name,\\n\\t\\tDataText:    string(dataJSON),\\n\\t\\tStage:       lib.StageDataroom,\\n\\t\\tCreatedBy:   actorID,\\n\\t\\tCreatedAt:   now,\\n\\t\\tUpdatedAt:   now,\\n\\t\\tVersion:     1,\\n\\t\\tKeyVersion:  1,\\n\\t}\\n\\n\\t// Pack encrypted fields\\n\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tsummary, err := lib.Pack(key, entry.SummaryText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdata, err := lib.Pack(key, entry.DataText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tentry.Summary = summary\\n\\tentry.Data = data\\n\\n\\t// Direct insert (bypass RBAC since orgs are platform-level)\\n\\t_, dbErr := h.DB.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\tentry.EntryID, entry.ProjectID, \\\"\\\", entry.Type, entry.Depth, 0,\\n\\t\\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\\n\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\tentry.Version, nil, nil, entry.KeyVersion,\\n\\t\\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\\n\\t)\\n\\tif dbErr != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create organization\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]any{\\n\\t\\t\\\"entry_id\\\": orgID,\\n\\t\\t\\\"name\\\":     req.Name,\\n\\t\\t\\\"domains\\\":  req.Domains,\\n\\t\\t\\\"role\\\":     req.Role,\\n\\t})\\n}\\n\\nfunc (h *Handlers) isIBAdminAnywhere(userID string) bool {\\n\\tvar count int\\n\\th.DB.Conn.QueryRow(\\n\\t\\t`SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`,\\n\\t\\tuserID, lib.RoleIBAdmin,\\n\\t).Scan(\\u0026count)\\n\\treturn count \\u003e 0\\n}\\n\\n// GetOrg handles GET /api/orgs/{orgID} — get a single organization\\nfunc (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) {\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, h.orgToMap(org))\\n}\\n\\n// UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization\\n// OrgDeals returns a list of deals (projects) this org is currently part of.\\n// UpdateOrgMembers handles PUT /api/orgs/{orgID}/members — replaces full members list.\\nfunc (h *Handlers) UpdateOrgMembers(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar body struct {\\n\\t\\tMembers []lib.DealOrgMember `json:\\\"members\\\"`\\n\\t\\tVersion int                 `json:\\\"version\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar orgData lib.OrgData\\n\\tjson.Unmarshal([]byte(org.DataText), \\u0026orgData)\\n\\torgData.Members = body.Members\\n\\tdataJSON, _ := json.Marshal(orgData)\\n\\torg.DataText = string(dataJSON)\\n\\torg.Version = body.Version\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {\\n\\t\\tif err == lib.ErrVersionConflict {\\n\\t\\t\\tErrorResponse(w, http.StatusConflict, \\\"version_conflict\\\", err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update members\\\")\\n\\t\\treturn\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"ok\\\": true, \\\"version\\\": org.Version})\\n}\\n\\n// RescrapeOrg scrapes the org website for people and diffs against existing members.\\nfunc (h *Handlers) RescrapeOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar orgData lib.OrgData\\n\\tjson.Unmarshal([]byte(org.DataText), \\u0026orgData)\\n\\n\\tif orgData.Website == \\\"\\\" \\u0026\\u0026 len(orgData.Domains) == 0 {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"no_website\\\", \\\"No website or domain configured for this org\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\twebsite := orgData.Website\\n\\tif website == \\\"\\\" {\\n\\t\\twebsite = \\\"https://\\\" + orgData.Domains[0]\\n\\t}\\n\\n\\t// Extract domain from website\\n\\tdomain := strings.TrimPrefix(strings.TrimPrefix(website, \\\"https://\\\"), \\\"http://\\\")\\n\\tdomain = strings.TrimPrefix(domain, \\\"www.\\\")\\n\\tdomain = strings.Split(domain, \\\"/\\\")[0]\\n\\n\\tscraped, err := lib.ScrapeOrg(h.Cfg.OpenRouterKey, domain)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"scrape_failed\\\", \\\"Scrape failed: \\\"+err.Error())\\n\\t\\treturn\\n\\t}\\n\\n\\tpeople := scraped.People\\n\\ttruncated := false // no hard cap — return all found\\n\\n\\t// Diff: existing members keyed by email (lowercased) or name\\n\\ttype MemberStatus struct {\\n\\t\\tPerson   lib.DealOrgMember `json:\\\"person\\\"`\\n\\t\\tStatus   string            `json:\\\"status\\\"` // \\\"existing\\\" | \\\"new\\\" | \\\"not_found\\\"\\n\\t}\\n\\n\\texistingByEmail := map[string]lib.DealOrgMember{}\\n\\texistingByName := map[string]lib.DealOrgMember{}\\n\\tfor _, m := range orgData.Members {\\n\\t\\tif m.Email != \\\"\\\" {\\n\\t\\t\\texistingByEmail[strings.ToLower(m.Email)] = m\\n\\t\\t}\\n\\t\\tif m.Name != \\\"\\\" {\\n\\t\\t\\texistingByName[strings.ToLower(m.Name)] = m\\n\\t\\t}\\n\\t}\\n\\n\\tscrapedEmails := map[string]bool{}\\n\\tscrapedNames := map[string]bool{}\\n\\tvar newPeople []lib.DealOrgMember\\n\\tfor _, p := range people {\\n\\t\\temail := strings.ToLower(p.Email)\\n\\t\\tname := strings.ToLower(p.Name)\\n\\t\\tif email != \\\"\\\" { scrapedEmails[email] = true }\\n\\t\\tif name != \\\"\\\" { scrapedNames[name] = true }\\n\\t\\t_, foundByEmail := existingByEmail[email]\\n\\t\\t_, foundByName := existingByName[name]\\n\\t\\tif !foundByEmail \\u0026\\u0026 !foundByName {\\n\\t\\t\\tnewPeople = append(newPeople, lib.DealOrgMember{\\n\\t\\t\\t\\tName: p.Name, Email: p.Email, Title: p.Title,\\n\\t\\t\\t\\tPhone: p.Phone, Photo: p.Photo, LinkedIn: p.LinkedIn,\\n\\t\\t\\t})\\n\\t\\t}\\n\\t}\\n\\n\\tvar notFound []lib.DealOrgMember\\n\\tfor _, m := range orgData.Members {\\n\\t\\temailKey := strings.ToLower(m.Email)\\n\\t\\tnameKey := strings.ToLower(m.Name)\\n\\t\\tfoundByEmail := m.Email != \\\"\\\" \\u0026\\u0026 scrapedEmails[emailKey]\\n\\t\\tfoundByName := m.Name != \\\"\\\" \\u0026\\u0026 scrapedNames[nameKey]\\n\\t\\tif !foundByEmail \\u0026\\u0026 !foundByName {\\n\\t\\t\\tnotFound = append(notFound, m)\\n\\t\\t}\\n\\t}\\n\\n\\tif newPeople == nil { newPeople = []lib.DealOrgMember{} }\\n\\tif notFound == nil { notFound = []lib.DealOrgMember{} }\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"new_people\\\":  newPeople,\\n\\t\\t\\\"not_found\\\":   notFound,\\n\\t\\t\\\"truncated\\\":   truncated,\\n\\t\\t\\\"total_found\\\": len(people),\\n\\t})\\n}\\n\\nfunc (h *Handlers) OrgDeals(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Find all deal_org entries referencing this org_id\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT e.project_id FROM entries e\\n\\t\\t WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Query failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar projectIDs []string\\n\\tfor rows.Next() {\\n\\t\\tvar pid string\\n\\t\\trows.Scan(\\u0026pid)\\n\\t\\t// We need to decrypt each to check org_id — collect all first\\n\\t\\tprojectIDs = append(projectIDs, pid)\\n\\t}\\n\\n\\t// More efficient: query deal_orgs and decrypt to find org matches\\n\\trows2, err := h.DB.Conn.Query(\\n\\t\\t`SELECT e.entry_id, e.project_id, e.data, e.key_version FROM entries e\\n\\t\\t WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Query failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows2.Close()\\n\\n\\tmatchedProjectIDs := map[string]bool{}\\n\\tfor rows2.Next() {\\n\\t\\tvar entryID, projID string\\n\\t\\tvar data []byte\\n\\t\\tvar keyVer int\\n\\t\\trows2.Scan(\\u0026entryID, \\u0026projID, \\u0026data, \\u0026keyVer)\\n\\t\\tif len(data) == 0 { continue }\\n\\t\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID)\\n\\t\\tif err != nil { continue }\\n\\t\\tdataText, err := lib.Unpack(key, data)\\n\\t\\tif err != nil { continue }\\n\\t\\tvar dod lib.DealOrgData\\n\\t\\tif json.Unmarshal([]byte(dataText), \\u0026dod) == nil \\u0026\\u0026 dod.OrgID == orgID {\\n\\t\\t\\tmatchedProjectIDs[projID] = true\\n\\t\\t}\\n\\t}\\n\\n\\t// Fetch project names for matched project IDs\\n\\ttype dealRef struct {\\n\\t\\tProjectID string `json:\\\"project_id\\\"`\\n\\t\\tName      string `json:\\\"name\\\"`\\n\\t}\\n\\tvar deals []dealRef\\n\\tfor projID := range matchedProjectIDs {\\n\\t\\tproj, err := lib.EntryByID(h.DB, h.Cfg, projID)\\n\\t\\tif err != nil || proj == nil { continue }\\n\\t\\tname := proj.SummaryText\\n\\t\\tif name == \\\"\\\" { name = projID }\\n\\t\\tdeals = append(deals, dealRef{ProjectID: projID, Name: name})\\n\\t}\\n\\tif deals == nil { deals = []dealRef{} }\\n\\tJSONResponse(w, http.StatusOK, deals)\\n}\\n\\n// DeleteOrg hard-deletes an organization and all its deal_org links.\\nfunc (h *Handlers) DeleteOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Only IB admins or super admins can delete organizations\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Soft-delete the org entry\\n\\tnow := time.Now().UnixMilli()\\n\\t_, err = h.DB.Conn.Exec(\\n\\t\\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\\n\\t\\tnow, actorID, now, orgID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to delete organization\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Also soft-delete all deal_org entries that reference this org\\n\\t// (need to scan since org_id is encrypted in data)\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, data FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`,\\n\\t)\\n\\tif err == nil {\\n\\t\\tdefer rows.Close()\\n\\t\\tfor rows.Next() {\\n\\t\\t\\tvar entryID, projID string\\n\\t\\t\\tvar data []byte\\n\\t\\t\\trows.Scan(\\u0026entryID, \\u0026projID, \\u0026data)\\n\\t\\t\\tif len(data) == 0 { continue }\\n\\t\\t\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID)\\n\\t\\t\\tif err != nil { continue }\\n\\t\\t\\tdataText, err := lib.Unpack(key, data)\\n\\t\\t\\tif err != nil { continue }\\n\\t\\t\\tvar dod lib.DealOrgData\\n\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026dod) == nil \\u0026\\u0026 dod.OrgID == orgID {\\n\\t\\t\\t\\th.DB.Conn.Exec(\\n\\t\\t\\t\\t\\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\\n\\t\\t\\t\\t\\tnow, actorID, now, entryID,\\n\\t\\t\\t\\t)\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"ok\\\": true})\\n}\\n\\nfunc (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\torgID := chi.URLParam(r, \\\"orgID\\\")\\n\\n\\t// Check permissions\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tisIBAdmin := h.isIBAdminAnywhere(actorID)\\n\\tif !isSuperAdmin \\u0026\\u0026 !isIBAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Only IB admins or super admins can update organizations\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get existing org\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, orgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tName         *string  `json:\\\"name\\\"`\\n\\t\\tDomains      []string `json:\\\"domains\\\"`\\n\\t\\tRole         *string  `json:\\\"role\\\"`\\n\\t\\tWebsite      *string  `json:\\\"website\\\"`\\n\\t\\tDescription  *string  `json:\\\"description\\\"`\\n\\t\\tIndustry     *string  `json:\\\"industry\\\"`\\n\\t\\tPhone        *string  `json:\\\"phone\\\"`\\n\\t\\tAddress      *string  `json:\\\"address\\\"`\\n\\t\\tCity         *string  `json:\\\"city\\\"`\\n\\t\\tState        *string  `json:\\\"state\\\"`\\n\\t\\tFounded      *string  `json:\\\"founded\\\"`\\n\\t\\tLinkedIn     *string  `json:\\\"linkedin\\\"`\\n\\t\\tLogo         *string  `json:\\\"logo\\\"`\\n\\t\\tContactName  *string  `json:\\\"contact_name\\\"`\\n\\t\\tContactEmail *string  `json:\\\"contact_email\\\"`\\n\\t\\tVersion      int      `json:\\\"version\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Parse existing org data\\n\\tvar orgData lib.OrgData\\n\\tif org.DataText != \\\"\\\" {\\n\\t\\tjson.Unmarshal([]byte(org.DataText), \\u0026orgData)\\n\\t}\\n\\n\\t// Apply updates\\n\\tif req.Name != nil {\\n\\t\\torgData.Name = *req.Name\\n\\t}\\n\\tif req.Domains != nil {\\n\\t\\tif len(req.Domains) == 0 {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"At least one domain required\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tfor i := range req.Domains {\\n\\t\\t\\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\\n\\t\\t}\\n\\t\\torgData.Domains = req.Domains\\n\\t}\\n\\tif req.Role != nil {\\n\\t\\tvalidRoles := map[string]bool{\\\"seller\\\": true, \\\"buyer\\\": true, \\\"ib\\\": true, \\\"advisor\\\": true, \\\"\\\": true}\\n\\t\\tif !validRoles[*req.Role] {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_role\\\", \\\"Role must be one of: seller, buyer, ib, advisor\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\torgData.Role = *req.Role\\n\\t}\\n\\tif req.Website != nil {\\n\\t\\torgData.Website = *req.Website\\n\\t}\\n\\tif req.Description != nil {\\n\\t\\torgData.Description = *req.Description\\n\\t}\\n\\tif req.Industry != nil { orgData.Industry = *req.Industry }\\n\\tif req.Phone != nil { orgData.Phone = *req.Phone }\\n\\tif req.Address != nil { orgData.Address = *req.Address }\\n\\tif req.City != nil { orgData.City = *req.City }\\n\\tif req.State != nil { orgData.State = *req.State }\\n\\tif req.Founded != nil { orgData.Founded = *req.Founded }\\n\\tif req.LinkedIn != nil { orgData.LinkedIn = *req.LinkedIn }\\n\\tif req.Logo != nil { orgData.Logo = *req.Logo }\\n\\tif req.ContactName != nil { orgData.ContactName = *req.ContactName }\\n\\tif req.ContactEmail != nil { orgData.ContactEmail = *req.ContactEmail }\\n\\n\\tdataJSON, _ := json.Marshal(orgData)\\n\\torg.DataText = string(dataJSON)\\n\\torg.SummaryText = orgData.Name\\n\\torg.Version = req.Version\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {\\n\\t\\tif err == lib.ErrVersionConflict {\\n\\t\\t\\tErrorResponse(w, http.StatusConflict, \\\"version_conflict\\\", err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update organization\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, h.orgToMap(org))\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Deal Org API endpoints (per-project organization links)\\n// ---------------------------------------------------------------------------\\n\\n// ListDealOrgs handles GET /api/projects/{projectID}/orgs — list orgs in this deal\\n// UpdateDealOrg handles PATCH /api/projects/{projectID}/orgs/{dealOrgID}\\n// Updates org details (on the organization entry) and deal-specific role (on the deal_org entry).\\nfunc (h *Handlers) UpdateDealOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tdealOrgID := chi.URLParam(r, \\\"dealOrgID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar body struct {\\n\\t\\tName        string `json:\\\"name\\\"`\\n\\t\\tDescription string `json:\\\"description\\\"`\\n\\t\\tRole        string `json:\\\"role\\\"`\\n\\t\\tIndustry    string `json:\\\"industry\\\"`\\n\\t\\tWebsite     string `json:\\\"website\\\"`\\n\\t\\tPhone       string `json:\\\"phone\\\"`\\n\\t\\tAddress     string `json:\\\"address\\\"`\\n\\t\\tCity        string `json:\\\"city\\\"`\\n\\t\\tState       string `json:\\\"state\\\"`\\n\\t\\tFounded     string `json:\\\"founded\\\"`\\n\\t\\tLinkedIn    string `json:\\\"linkedin\\\"`\\n\\t\\tLogo        string `json:\\\"logo\\\"`\\n\\t\\tVersion     int    `json:\\\"version\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\tif strings.TrimSpace(body.Name) == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"name_required\\\", \\\"Name is required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Load the deal_org entry to get org_id and current data\\n\\tdealOrgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)\\n\\tif err != nil || dealOrgEntry == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Deal org not found\\\")\\n\\t\\treturn\\n\\t}\\n\\tvar dealOrgData lib.DealOrgData\\n\\tjson.Unmarshal([]byte(dealOrgEntry.DataText), \\u0026dealOrgData)\\n\\n\\t// Update role on deal_org if changed\\n\\tif body.Role != \\\"\\\" \\u0026\\u0026 body.Role != dealOrgData.Role {\\n\\t\\tdealOrgData.Role = body.Role\\n\\t\\tdealOrgJSON, _ := json.Marshal(dealOrgData)\\n\\t\\tdealOrgEntry.DataText = string(dealOrgJSON)\\n\\t\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, dealOrgEntry); err != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update deal org role\\\")\"}","is_error":false}},{"type":"finish","data":{"reason":"stop","time":0}}]


id: 8ca5abff-4fbf-43cd-b59f-7e1520eb91e9
session_id: functions.agent:1
role: assistant
model: local.accounts/fireworks/models/kimi-k2p5
parts:
  [{"type":"tool_call","data":{"id":"functions.view:4","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/api/handlers.go\", \"offset\": 2000}","type":"function","finished":true}},{"type":"tool_call","data":{"id":"functions.view:5","name":"view","input":"{\"file_path\": \"/home/johan/dev/dealspace/migrations/001_initial.sql\"}","type":"function","finished":true}},{"type":"tool_call","data":{"id":"functions.ls:6","name":"ls","input":"{\"path\": \"/home/johan/dev/dealspace/portal/templates/app\"}","type":"function","finished":true}},{"type":"finish","data":{"reason":"tool_use","time":1774088514}}]


id: 54f2417b-81a7-46e5-a24a-01e4fcab61ac
session_id: functions.agent:1
role: tool
parts:
  [{"type":"tool_result","data":{"tool_call_id":"functions.view:4","name":"","content":"\u003cfile\u003e\n  2001|\t\t\treturn\n  2002|\t\t}\n  2003|\t}\n  2004|\n  2005|\t// Load the organization entry and update its details\n  2006|\torgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)\n  2007|\tif err != nil || orgEntry == nil {\n  2008|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  2009|\t\treturn\n  2010|\t}\n  2011|\tvar orgData lib.OrgData\n  2012|\tjson.Unmarshal([]byte(orgEntry.DataText), \u0026orgData)\n  2013|\n  2014|\torgData.Name = strings.TrimSpace(body.Name)\n  2015|\torgData.Description = body.Description\n  2016|\tif body.Role != \"\" { orgData.Role = body.Role }\n  2017|\torgData.Industry = body.Industry\n  2018|\torgData.Website = body.Website\n  2019|\torgData.Phone = body.Phone\n  2020|\torgData.Address = body.Address\n  2021|\torgData.City = body.City\n  2022|\torgData.State = body.State\n  2023|\torgData.Founded = body.Founded\n  2024|\torgData.LinkedIn = body.LinkedIn\n  2025|\tif body.Logo != \"\" { orgData.Logo = body.Logo }\n  2026|\n  2027|\torgJSON, _ := json.Marshal(orgData)\n  2028|\torgEntry.DataText = string(orgJSON)\n  2029|\torgEntry.SummaryText = orgData.Name\n  2030|\n  2031|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, orgEntry); err != nil {\n  2032|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update organization\")\n  2033|\t\treturn\n  2034|\t}\n  2035|\n  2036|\tJSONResponse(w, http.StatusOK, map[string]any{\"ok\": true})\n  2037|}\n  2038|\n  2039|func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {\n  2040|\tactorID := UserIDFromContext(r.Context())\n  2041|\tprojectID := chi.URLParam(r, \"projectID\")\n  2042|\n  2043|\t// Check read access to project\n  2044|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n  2045|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2046|\t\treturn\n  2047|\t}\n  2048|\n  2049|\t// Get all deal_org entries for this project\n  2050|\trows, err := h.DB.Conn.Query(\n  2051|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n  2052|\t\t        search_key, search_key2, summary, data, stage,\n  2053|\t\t        assignee_id, return_to_id, origin_id,\n  2054|\t\t        version, deleted_at, deleted_by, key_version,\n  2055|\t\t        created_at, updated_at, created_by\n  2056|\t\t FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL\n  2057|\t\t ORDER BY created_at DESC`, projectID,\n  2058|\t)\n  2059|\tif err != nil {\n  2060|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list deal organizations\")\n  2061|\t\treturn\n  2062|\t}\n  2063|\tdefer rows.Close()\n  2064|\n  2065|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  2066|\n  2067|\tvar result []map[string]any\n  2068|\tfor rows.Next() {\n  2069|\t\tvar e lib.Entry\n  2070|\t\terr := rows.Scan(\n  2071|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  2072|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  2073|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  2074|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  2075|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  2076|\t\t)\n  2077|\t\tif err != nil {\n  2078|\t\t\tcontinue\n  2079|\t\t}\n  2080|\n  2081|\t\tdealOrgMap := map[string]any{\n  2082|\t\t\t\"deal_org_id\": e.EntryID,\n  2083|\t\t\t\"created_at\":  e.CreatedAt,\n  2084|\t\t\t\"version\":     e.Version,\n  2085|\t\t}\n  2086|\n  2087|\t\t// Decrypt deal_org data\n  2088|\t\tif len(e.Data) \u003e 0 {\n  2089|\t\t\tdataText, err := lib.Unpack(projectKey, e.Data)\n  2090|\t\t\tif err == nil {\n  2091|\t\t\t\tvar dealOrgData lib.DealOrgData\n  2092|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026dealOrgData) == nil {\n  2093|\t\t\t\t\tdealOrgMap[\"org_id\"] = dealOrgData.OrgID\n  2094|\t\t\t\t\tdealOrgMap[\"role\"] = dealOrgData.Role\n  2095|\t\t\t\t\tdealOrgMap[\"domain_lock\"] = dealOrgData.DomainLock\n  2096|\t\t\t\t\tdealOrgMap[\"permissions\"] = dealOrgData.Permissions\n  2097|\t\t\t\t\tdealOrgMap[\"members\"] = dealOrgData.Members\n  2098|\n  2099|\t\t\t\t\t// Fetch org details\n  2100|\t\t\t\t\torg, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)\n  2101|\t\t\t\t\tif err == nil \u0026\u0026 org != nil {\n  2102|\t\t\t\t\t\torgDetails := h.orgToMap(org)\n  2103|\t\t\t\t\t\tdealOrgMap[\"org_name\"] = orgDetails[\"name\"]\n  2104|\t\t\t\t\t\tdealOrgMap[\"org_domains\"] = orgDetails[\"domains\"]\n  2105|\t\t\t\t\t\tdealOrgMap[\"org_logo\"] = orgDetails[\"logo\"]\n  2106|\t\t\t\t\t\tdealOrgMap[\"org_description\"] = orgDetails[\"description\"]\n  2107|\t\t\t\t\t\tdealOrgMap[\"org_industry\"] = orgDetails[\"industry\"]\n  2108|\t\t\t\t\t\tdealOrgMap[\"org_website\"] = orgDetails[\"website\"]\n  2109|\t\t\t\t\t\tdealOrgMap[\"org_phone\"] = orgDetails[\"phone\"]\n  2110|\t\t\t\t\t\tdealOrgMap[\"org_address\"] = orgDetails[\"address\"]\n  2111|\t\t\t\t\t\tdealOrgMap[\"org_city\"] = orgDetails[\"city\"]\n  2112|\t\t\t\t\t\tdealOrgMap[\"org_state\"] = orgDetails[\"state\"]\n  2113|\t\t\t\t\t\tdealOrgMap[\"org_founded\"] = orgDetails[\"founded\"]\n  2114|\t\t\t\t\t\tdealOrgMap[\"org_linkedin\"] = orgDetails[\"linkedin\"]\n  2115|\t\t\t\t\t}\n  2116|\t\t\t\t}\n  2117|\t\t\t}\n  2118|\t\t}\n  2119|\n  2120|\t\tresult = append(result, dealOrgMap)\n  2121|\t}\n  2122|\n  2123|\tif result == nil {\n  2124|\t\tresult = []map[string]any{}\n  2125|\t}\n  2126|\n  2127|\tJSONResponse(w, http.StatusOK, result)\n  2128|}\n  2129|\n  2130|// CreateDealOrg handles POST /api/projects/{projectID}/orgs — add org to deal\n  2131|func (h *Handlers) CreateDealOrg(w http.ResponseWriter, r *http.Request) {\n  2132|\tactorID := UserIDFromContext(r.Context())\n  2133|\tprojectID := chi.URLParam(r, \"projectID\")\n  2134|\n  2135|\t// Check write access to project\n  2136|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2137|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2138|\t\treturn\n  2139|\t}\n  2140|\n  2141|\tvar req struct {\n  2142|\t\tOrgID      string `json:\"org_id\"`\n  2143|\t\tRole       string `json:\"role\"`\n  2144|\t\tDomainLock bool   `json:\"domain_lock\"`\n  2145|\t}\n  2146|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  2147|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  2148|\t\treturn\n  2149|\t}\n  2150|\n  2151|\tif req.OrgID == \"\" {\n  2152|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"org_id required\")\n  2153|\t\treturn\n  2154|\t}\n  2155|\n  2156|\t// Verify org exists\n  2157|\torg, err := lib.EntryByID(h.DB, h.Cfg, req.OrgID)\n  2158|\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\n  2159|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Organization not found\")\n  2160|\t\treturn\n  2161|\t}\n  2162|\n  2163|\t// Get project entry to use as parent\n  2164|\tproject, err := lib.EntryByID(h.DB, h.Cfg, projectID)\n  2165|\tif err != nil || project == nil {\n  2166|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Project not found\")\n  2167|\t\treturn\n  2168|\t}\n  2169|\n  2170|\tnow := time.Now().UnixMilli()\n  2171|\tdealOrgID := uuid.New().String()\n  2172|\n  2173|\tdealOrgData := lib.DealOrgData{\n  2174|\t\tOrgID:      req.OrgID,\n  2175|\t\tRole:       req.Role,\n  2176|\t\tDomainLock: req.DomainLock,\n  2177|\t}\n  2178|\tdataJSON, _ := json.Marshal(dealOrgData)\n  2179|\n  2180|\tentry := \u0026lib.Entry{\n  2181|\t\tEntryID:     dealOrgID,\n  2182|\t\tProjectID:   projectID,\n  2183|\t\tParentID:    projectID, // Parent is the project\n  2184|\t\tType:        lib.TypeDealOrg,\n  2185|\t\tDepth:       1,\n  2186|\t\tSummaryText: req.Role,\n  2187|\t\tDataText:    string(dataJSON),\n  2188|\t\tStage:       lib.StagePreDataroom,\n  2189|\t\tCreatedBy:   actorID,\n  2190|\t\tCreatedAt:   now,\n  2191|\t\tUpdatedAt:   now,\n  2192|\t\tVersion:     1,\n  2193|\t\tKeyVersion:  1,\n  2194|\t}\n  2195|\n  2196|\t// Pack encrypted fields\n  2197|\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  2198|\tif err != nil {\n  2199|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n  2200|\t\treturn\n  2201|\t}\n  2202|\tsummary, err := lib.Pack(key, entry.SummaryText)\n  2203|\tif err != nil {\n  2204|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n  2205|\t\treturn\n  2206|\t}\n  2207|\tdata, err := lib.Pack(key, entry.DataText)\n  2208|\tif err != nil {\n  2209|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Encryption failed\")\n  2210|\t\treturn\n  2211|\t}\n  2212|\tentry.Summary = summary\n  2213|\tentry.Data = data\n  2214|\n  2215|\t_, dbErr := h.DB.Conn.Exec(\n  2216|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  2217|\t\t  search_key, search_key2, summary, data, stage,\n  2218|\t\t  assignee_id, return_to_id, origin_id,\n  2219|\t\t  version, deleted_at, deleted_by, key_version,\n  2220|\t\t  created_at, updated_at, created_by)\n  2221|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  2222|\t\tentry.EntryID, entry.ProjectID, entry.ParentID, entry.Type, entry.Depth, 0,\n  2223|\t\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\n  2224|\t\t\"\", \"\", \"\",\n  2225|\t\tentry.Version, nil, nil, entry.KeyVersion,\n  2226|\t\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\n  2227|\t)\n  2228|\tif dbErr != nil {\n  2229|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to add organization to deal\")\n  2230|\t\treturn\n  2231|\t}\n  2232|\n  2233|\t// Get org name for response\n  2234|\torgName := \"\"\n  2235|\torgDetails := h.orgToMap(org)\n  2236|\tif name, ok := orgDetails[\"name\"].(string); ok {\n  2237|\t\torgName = name\n  2238|\t}\n  2239|\n  2240|\tJSONResponse(w, http.StatusCreated, map[string]any{\n  2241|\t\t\"deal_org_id\": dealOrgID,\n  2242|\t\t\"org_id\":      req.OrgID,\n  2243|\t\t\"org_name\":    orgName,\n  2244|\t\t\"role\":        req.Role,\n  2245|\t\t\"domain_lock\": req.DomainLock,\n  2246|\t})\n  2247|}\n  2248|\n  2249|// DeleteDealOrg handles DELETE /api/projects/{projectID}/orgs/{dealOrgID} — remove org from deal\n  2250|func (h *Handlers) DeleteDealOrg(w http.ResponseWriter, r *http.Request) {\n  2251|\tactorID := UserIDFromContext(r.Context())\n  2252|\tprojectID := chi.URLParam(r, \"projectID\")\n  2253|\tdealOrgID := chi.URLParam(r, \"dealOrgID\")\n  2254|\n  2255|\t// Check delete access to project\n  2256|\tif err := lib.CheckAccessDelete(h.DB, actorID, projectID, \"\"); err != nil {\n  2257|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2258|\t\treturn\n  2259|\t}\n  2260|\n  2261|\t// Verify deal_org exists and belongs to this project\n  2262|\tentry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)\n  2263|\tif err != nil || entry == nil || entry.Type != lib.TypeDealOrg || entry.ProjectID != projectID {\n  2264|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Deal organization link not found\")\n  2265|\t\treturn\n  2266|\t}\n  2267|\n  2268|\tif err := lib.EntryDelete(h.DB, actorID, projectID, dealOrgID); err != nil {\n  2269|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to remove organization from deal\")\n  2270|\t\treturn\n  2271|\t}\n  2272|\n  2273|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"deleted\"})\n  2274|}\n  2275|\n  2276|// ServeAppOrgs serves the organizations page\n  2277|func (h *Handlers) ServeAppOrgs(w http.ResponseWriter, r *http.Request) {\n  2278|\th.serveTemplatePage(w, \"app\", \"app/orgs.html\", PageData{Title: \"Organizations — Dealspace\", ActiveNav: \"orgs\"})\n  2279|}\n  2280|\n  2281|// serveWebsitePage renders a website page inside the website base layout.\n  2282|func (h *Handlers) serveWebsitePage(w http.ResponseWriter, page string) {\n  2283|\tlayoutCandidates := []string{\n  2284|\t\t\"website/layouts/base.html\",\n  2285|\t\t\"/opt/dealspace/website/layouts/base.html\",\n  2286|\t}\n  2287|\tpageCandidates := []string{\n  2288|\t\tpage,\n  2289|\t\tfilepath.Join(\"website/pages\", page),\n  2290|\t\tfilepath.Join(\"/opt/dealspace/website/pages\", page),\n  2291|\t}\n  2292|\n  2293|\tvar layoutPath, pagePath string\n  2294|\tfor _, p := range layoutCandidates {\n  2295|\t\tif _, err := os.Stat(p); err == nil {\n  2296|\t\t\tlayoutPath = p\n  2297|\t\t\tbreak\n  2298|\t\t}\n  2299|\t}\n  2300|\tfor _, p := range pageCandidates {\n  2301|\t\tif _, err := os.Stat(p); err == nil {\n  2302|\t\t\tpagePath = p\n  2303|\t\t\tbreak\n  2304|\t\t}\n  2305|\t}\n  2306|\n  2307|\tif layoutPath == \"\" || pagePath == \"\" {\n  2308|\t\thttp.Error(w, \"Template not found\", http.StatusInternalServerError)\n  2309|\t\treturn\n  2310|\t}\n  2311|\n  2312|\ttmpl, err := template.ParseFiles(layoutPath, pagePath)\n  2313|\tif err != nil {\n  2314|\t\thttp.Error(w, \"Template parse error: \"+err.Error(), http.StatusInternalServerError)\n  2315|\t\treturn\n  2316|\t}\n  2317|\n  2318|\tw.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n  2319|\tif err := tmpl.ExecuteTemplate(w, \"layout\", nil); err != nil {\n  2320|\t\tlog.Printf(\"website template error: %v\", err)\n  2321|\t}\n  2322|}\n  2323|\n  2324|// ServeWebsiteIndex serves the homepage\n  2325|func (h *Handlers) ServeWebsiteIndex(w http.ResponseWriter, r *http.Request) {\n  2326|\th.serveWebsitePage(w, \"index.html\")\n  2327|}\n  2328|\n  2329|// ServeWebsiteFeatures serves the features page\n  2330|func (h *Handlers) ServeWebsiteFeatures(w http.ResponseWriter, r *http.Request) {\n  2331|\th.serveWebsitePage(w, \"features.html\")\n  2332|}\n  2333|\n  2334|// ServeWebsitePricing serves the pricing page\n  2335|func (h *Handlers) ServeWebsitePricing(w http.ResponseWriter, r *http.Request) {\n  2336|\th.serveWebsitePage(w, \"pricing.html\")\n  2337|}\n  2338|\n  2339|// ServeWebsiteSecurity serves the security page\n  2340|func (h *Handlers) ServeWebsiteSecurity(w http.ResponseWriter, r *http.Request) {\n  2341|\th.serveWebsitePage(w, \"security.html\")\n  2342|}\n  2343|\n  2344|// ServeWebsitePrivacy serves the privacy page\n  2345|func (h *Handlers) ServeWebsitePrivacy(w http.ResponseWriter, r *http.Request) {\n  2346|\th.serveWebsitePage(w, \"privacy.html\")\n  2347|}\n  2348|\n  2349|// ServeWebsiteTerms serves the terms page\n  2350|func (h *Handlers) ServeWebsiteTerms(w http.ResponseWriter, r *http.Request) {\n  2351|\th.serveWebsitePage(w, \"terms.html\")\n  2352|}\n  2353|\n  2354|// ServeWebsiteDPA serves the DPA page\n  2355|func (h *Handlers) ServeWebsiteDPA(w http.ResponseWriter, r *http.Request) {\n  2356|\th.serveWebsitePage(w, \"dpa.html\")\n  2357|}\n  2358|\n  2359|// ServeWebsiteSOC2 serves the SOC 2 page\n  2360|func (h *Handlers) ServeWebsiteSOC2(w http.ResponseWriter, r *http.Request) {\n  2361|\th.serveWebsitePage(w, \"soc2.html\")\n  2362|}\n  2363|\n  2364|// ---------------------------------------------------------------------------\n  2365|// Request Import/List API endpoints\n  2366|// ---------------------------------------------------------------------------\n  2367|\n  2368|// ListRequests handles GET /api/projects/{projectID}/requests\n  2369|// Returns all request entries for a project, sorted by section + item_number\n  2370|// ListTemplates returns all saved request templates.\n  2371|func (h *Handlers) ListTemplates(w http.ResponseWriter, r *http.Request) {\n  2372|\ttemplates, err := lib.ListTemplates(h.DB, h.Cfg)\n  2373|\tif err != nil {\n  2374|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list templates\")\n  2375|\t\treturn\n  2376|\t}\n  2377|\tresult := []map[string]any{}\n  2378|\tfor _, t := range templates {\n  2379|\t\tvar data lib.RequestTemplateData\n  2380|\t\tjson.Unmarshal([]byte(t.DataText), \u0026data)\n  2381|\t\tresult = append(result, map[string]any{\n  2382|\t\t\t\"entry_id\":    t.EntryID,\n  2383|\t\t\t\"name\":        data.Name,\n  2384|\t\t\t\"description\": data.Description,\n  2385|\t\t\t\"item_count\":  len(data.Items),\n  2386|\t\t\t\"created_at\":  t.CreatedAt,\n  2387|\t\t})\n  2388|\t}\n  2389|\t// Also include built-in disk templates as a fallback\n  2390|\tJSONResponse(w, http.StatusOK, result)\n  2391|}\n  2392|\n  2393|// SaveTemplate saves a request list as a reusable template.\n  2394|func (h *Handlers) SaveTemplate(w http.ResponseWriter, r *http.Request) {\n  2395|\tactorID := UserIDFromContext(r.Context())\n  2396|\tvar body struct {\n  2397|\t\tName        string                      `json:\"name\"`\n  2398|\t\tDescription string                      `json:\"description\"`\n  2399|\t\tItems       []lib.RequestTemplateItem   `json:\"items\"`\n  2400|\t}\n  2401|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil || strings.TrimSpace(body.Name) == \"\" {\n  2402|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"name and items required\")\n  2403|\t\treturn\n  2404|\t}\n  2405|\tdata := lib.RequestTemplateData{\n  2406|\t\tName:        strings.TrimSpace(body.Name),\n  2407|\t\tDescription: body.Description,\n  2408|\t\tItems:       body.Items,\n  2409|\t}\n  2410|\tdataJSON, _ := json.Marshal(data)\n  2411|\tentry := \u0026lib.Entry{\n  2412|\t\tProjectID:   lib.TemplateProjectID,\n  2413|\t\tParentID:    lib.TemplateProjectID,\n  2414|\t\tType:        lib.TypeRequestTemplate,\n  2415|\t\tDepth:       1,\n  2416|\t\tSortOrder:   0,\n  2417|\t\tSummaryText: data.Name,\n  2418|\t\tDataText:    string(dataJSON),\n  2419|\t\tStage:       lib.StageDataroom,\n  2420|\t}\n  2421|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n  2422|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to save template\")\n  2423|\t\treturn\n  2424|\t}\n  2425|\tJSONResponse(w, http.StatusOK, map[string]any{\"entry_id\": entry.EntryID, \"name\": data.Name})\n  2426|}\n  2427|\n  2428|// ImportTemplate imports a template (built-in or saved) into a project as a new request list.\n  2429|func (h *Handlers) ImportTemplate(w http.ResponseWriter, r *http.Request) {\n  2430|\tactorID := UserIDFromContext(r.Context())\n  2431|\tprojectID := chi.URLParam(r, \"projectID\")\n  2432|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2433|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2434|\t\treturn\n  2435|\t}\n  2436|\n  2437|\tvar body struct {\n  2438|\t\tTemplateID string `json:\"template_id\"` // entry_id of saved template, or built-in name\n  2439|\t\tName       string `json:\"name\"`        // override list name (optional)\n  2440|\t}\n  2441|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil || body.TemplateID == \"\" {\n  2442|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"template_id required\")\n  2443|\t\treturn\n  2444|\t}\n  2445|\n  2446|\tvar tmplData lib.RequestTemplateData\n  2447|\n  2448|\t// Try loading from DB first\n  2449|\ttmplEntry, err := lib.TemplateByID(h.DB, h.Cfg, body.TemplateID)\n  2450|\tif err == nil \u0026\u0026 tmplEntry != nil {\n  2451|\t\tjson.Unmarshal([]byte(tmplEntry.DataText), \u0026tmplData)\n  2452|\t} else {\n  2453|\t\t// Fall back to disk templates\n  2454|\t\tdirs := []string{\"templates\", \"/opt/dealspace/templates\"}\n  2455|\t\tloaded := false\n  2456|\t\tfor _, dir := range dirs {\n  2457|\t\t\tfp := dir + \"/\" + body.TemplateID + \".json\"\n  2458|\t\t\tif data, err := os.ReadFile(fp); err == nil {\n  2459|\t\t\t\tjson.Unmarshal(data, \u0026tmplData)\n  2460|\t\t\t\tloaded = true\n  2461|\t\t\t\tbreak\n  2462|\t\t\t}\n  2463|\t\t}\n  2464|\t\tif !loaded {\n  2465|\t\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Template not found\")\n  2466|\t\t\treturn\n  2467|\t\t}\n  2468|\t}\n  2469|\n  2470|\tlistName := strings.TrimSpace(body.Name)\n  2471|\tif listName == \"\" {\n  2472|\t\tlistName = tmplData.Name\n  2473|\t}\n  2474|\n  2475|\t// Create the request list\n  2476|\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID)\n  2477|\tlistCount := 0\n  2478|\tfor _, s := range siblings {\n  2479|\t\tif s.Type == lib.TypeRequestList {\n  2480|\t\t\tlistCount++\n  2481|\t\t}\n  2482|\t}\n  2483|\trlData := lib.RequestListData{Name: listName}\n  2484|\trlDataJSON, _ := json.Marshal(rlData)\n  2485|\tlistEntry := \u0026lib.Entry{\n  2486|\t\tProjectID:   projectID,\n  2487|\t\tParentID:    projectID,\n  2488|\t\tType:        lib.TypeRequestList,\n  2489|\t\tDepth:       1,\n  2490|\t\tSortOrder:   (listCount + 1) * 1000,\n  2491|\t\tSummaryText: listName,\n  2492|\t\tDataText:    string(rlDataJSON),\n  2493|\t\tStage:       lib.StagePreDataroom,\n  2494|\t}\n  2495|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, listEntry); err != nil {\n  2496|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create request list\")\n  2497|\t\treturn\n  2498|\t}\n  2499|\n  2500|\t// Group items by section, create section entries then requests\n  2501|\ttype sectionGroup struct {\n  2502|\t\tname  string\n  2503|\t\titems []lib.RequestTemplateItem\n  2504|\t}\n  2505|\tvar sections []sectionGroup\n  2506|\tsectionIndex := map[string]int{}\n  2507|\tfor _, item := range tmplData.Items {\n  2508|\t\tsec := item.Section\n  2509|\t\tif sec == \"\" {\n  2510|\t\t\tsec = \"General\"\n  2511|\t\t}\n  2512|\t\tif idx, ok := sectionIndex[sec]; ok {\n  2513|\t\t\tsections[idx].items = append(sections[idx].items, item)\n  2514|\t\t} else {\n  2515|\t\t\tsectionIndex[sec] = len(sections)\n  2516|\t\t\tsections = append(sections, sectionGroup{name: sec, items: []lib.RequestTemplateItem{item}})\n  2517|\t\t}\n  2518|\t}\n  2519|\n  2520|\tfor si, sg := range sections {\n  2521|\t\tsecData := lib.SectionData{Name: sg.name}\n  2522|\t\tsecDataJSON, _ := json.Marshal(secData)\n  2523|\t\tsecEntry := \u0026lib.Entry{\n  2524|\t\t\tProjectID:   projectID,\n  2525|\t\t\tParentID:    listEntry.EntryID,\n  2526|\t\t\tType:        lib.TypeSection,\n  2527|\t\t\tDepth:       2,\n  2528|\t\t\tSortOrder:   (si + 1) * 1000,\n  2529|\t\t\tSummaryText: sg.name,\n  2530|\t\t\tDataText:    string(secDataJSON),\n  2531|\t\t\tStage:       lib.StagePreDataroom,\n  2532|\t\t}\n  2533|\t\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, secEntry); err != nil {\n  2534|\t\t\tcontinue\n  2535|\t\t}\n  2536|\t\tfor ri, item := range sg.items {\n  2537|\t\t\treqData := lib.RequestData{\n  2538|\t\t\t\tTitle:      item.Title,\n  2539|\t\t\t\tItemNumber: item.ItemNumber,\n  2540|\t\t\t\tSection:    sg.name,\n  2541|\t\t\t\tPriority:   item.Priority,\n  2542|\t\t\t\tStatus:     \"open\",\n  2543|\t\t\t}\n  2544|\t\t\tif reqData.Priority == \"\" {\n  2545|\t\t\t\treqData.Priority = \"medium\"\n  2546|\t\t\t}\n  2547|\t\t\treqDataJSON, _ := json.Marshal(reqData)\n  2548|\t\t\treqEntry := \u0026lib.Entry{\n  2549|\t\t\t\tProjectID:   projectID,\n  2550|\t\t\t\tParentID:    secEntry.EntryID,\n  2551|\t\t\t\tType:        lib.TypeRequest,\n  2552|\t\t\t\tDepth:       3,\n  2553|\t\t\t\tSortOrder:   (ri + 1) * 100,\n  2554|\t\t\t\tSummaryText: item.Title,\n  2555|\t\t\t\tDataText:    string(reqDataJSON),\n  2556|\t\t\t\tStage:       lib.StagePreDataroom,\n  2557|\t\t\t}\n  2558|\t\t\tlib.EntryWrite(h.DB, h.Cfg, actorID, reqEntry)\n  2559|\t\t}\n  2560|\t}\n  2561|\n  2562|\tJSONResponse(w, http.StatusOK, map[string]any{\n  2563|\t\t\"entry_id\": listEntry.EntryID,\n  2564|\t\t\"name\":     listName,\n  2565|\t\t\"sections\": len(sections),\n  2566|\t\t\"items\":    len(tmplData.Items),\n  2567|\t})\n  2568|}\n  2569|\n  2570|func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) {\n  2571|\tactorID := UserIDFromContext(r.Context())\n  2572|\tprojectID := chi.URLParam(r, \"projectID\")\n  2573|\n  2574|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n  2575|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2576|\t\treturn\n  2577|\t}\n  2578|\n  2579|\t// Get all request entries for this project\n  2580|\trows, err := h.DB.Conn.Query(\n  2581|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n  2582|\t\t        search_key, search_key2, summary, data, stage,\n  2583|\t\t        assignee_id, return_to_id, origin_id,\n  2584|\t\t        version, deleted_at, deleted_by, key_version,\n  2585|\t\t        created_at, updated_at, created_by\n  2586|\t\t FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL\n  2587|\t\t ORDER BY sort_order ASC, created_at ASC`, projectID,\n  2588|\t)\n  2589|\tif err != nil {\n  2590|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list requests\")\n  2591|\t\treturn\n  2592|\t}\n  2593|\tdefer rows.Close()\n  2594|\n  2595|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  2596|\n  2597|\ttype RequestItem struct {\n  2598|\t\tEntryID    string          `json:\"entry_id\"`\n  2599|\t\tProjectID  string          `json:\"project_id\"`\n  2600|\t\tSection    string          `json:\"section\"`\n  2601|\t\tItemNumber string          `json:\"item_number\"`\n  2602|\t\tTitle      string          `json:\"title\"`\n  2603|\t\tPriority   string          `json:\"priority\"`\n  2604|\t\tStatus     string          `json:\"status\"`\n  2605|\t\tCreatedAt  int64           `json:\"created_at\"`\n  2606|\t\tData       lib.RequestData `json:\"data\"`\n  2607|\t}\n  2608|\n  2609|\tvar requests []RequestItem\n  2610|\tfor rows.Next() {\n  2611|\t\tvar e lib.Entry\n  2612|\t\terr := rows.Scan(\n  2613|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  2614|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  2615|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  2616|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  2617|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  2618|\t\t)\n  2619|\t\tif err != nil {\n  2620|\t\t\tcontinue\n  2621|\t\t}\n  2622|\n  2623|\t\titem := RequestItem{\n  2624|\t\t\tEntryID:   e.EntryID,\n  2625|\t\t\tProjectID: e.ProjectID,\n  2626|\t\t\tCreatedAt: e.CreatedAt,\n  2627|\t\t}\n  2628|\n  2629|\t\t// Decrypt data\n  2630|\t\tif len(e.Data) \u003e 0 {\n  2631|\t\t\tdataText, err := lib.Unpack(projectKey, e.Data)\n  2632|\t\t\tif err == nil {\n  2633|\t\t\t\tvar reqData lib.RequestData\n  2634|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026reqData) == nil {\n  2635|\t\t\t\t\titem.Section = reqData.Section\n  2636|\t\t\t\t\titem.ItemNumber = reqData.ItemNumber\n  2637|\t\t\t\t\titem.Title = reqData.Title\n  2638|\t\t\t\t\titem.Priority = reqData.Priority\n  2639|\t\t\t\t\titem.Status = reqData.Status\n  2640|\t\t\t\t\titem.Data = reqData\n  2641|\t\t\t\t}\n  2642|\t\t\t}\n  2643|\t\t}\n  2644|\n  2645|\t\trequests = append(requests, item)\n  2646|\t}\n  2647|\n  2648|\t// Sort by section, then item_number\n  2649|\t// (already sorted by created_at from query, which preserves import order)\n  2650|\n  2651|\tif requests == nil {\n  2652|\t\trequests = []RequestItem{}\n  2653|\t}\n  2654|\n  2655|\tJSONResponse(w, http.StatusOK, requests)\n  2656|}\n  2657|\n  2658|// ImportRequests handles POST /api/projects/{projectID}/requests/import\n  2659|// Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams\n  2660|// CreateSection creates a new section under a request_list.\n  2661|func (h *Handlers) CreateSection(w http.ResponseWriter, r *http.Request) {\n  2662|\tactorID := UserIDFromContext(r.Context())\n  2663|\tprojectID := chi.URLParam(r, \"projectID\")\n  2664|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2665|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\"); return\n  2666|\t}\n  2667|\tvar body struct {\n  2668|\t\tName     string `json:\"name\"`\n  2669|\t\tParentID string `json:\"parent_id\"`\n  2670|\t}\n  2671|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil || strings.TrimSpace(body.Name) == \"\" || body.ParentID == \"\" {\n  2672|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"name and parent_id required\"); return\n  2673|\t}\n  2674|\tparent, err := lib.EntryByID(h.DB, h.Cfg, body.ParentID)\n  2675|\tif err != nil || parent == nil {\n  2676|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_parent\", \"Parent entry not found\"); return\n  2677|\t}\n  2678|\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, body.ParentID)\n  2679|\tname := strings.TrimSpace(body.Name)\n  2680|\tsecData := lib.SectionData{Name: name}\n  2681|\tsecDataJSON, _ := json.Marshal(secData)\n  2682|\tentry := \u0026lib.Entry{\n  2683|\t\tProjectID:   projectID,\n  2684|\t\tParentID:    body.ParentID,\n  2685|\t\tType:        lib.TypeSection,\n  2686|\t\tDepth:       parent.Depth + 1,\n  2687|\t\tSortOrder:   (len(siblings) + 1) * 1000,\n  2688|\t\tSummaryText: name,\n  2689|\t\tDataText:    string(secDataJSON),\n  2690|\t\tStage:       lib.StagePreDataroom,\n  2691|\t}\n  2692|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n  2693|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create section\"); return\n  2694|\t}\n  2695|\tJSONResponse(w, http.StatusOK, map[string]any{\"entry_id\": entry.EntryID, \"name\": name})\n  2696|}\n  2697|\n  2698|// CreateRequest creates a new blank request under a section or request_list.\n  2699|func (h *Handlers) CreateRequest(w http.ResponseWriter, r *http.Request) {\n  2700|\tactorID := UserIDFromContext(r.Context())\n  2701|\tprojectID := chi.URLParam(r, \"projectID\")\n  2702|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2703|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\"); return\n  2704|\t}\n  2705|\tvar body struct {\n  2706|\t\tTitle    string `json:\"title\"`\n  2707|\t\tParentID string `json:\"parent_id\"`\n  2708|\t}\n  2709|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil || strings.TrimSpace(body.Title) == \"\" || body.ParentID == \"\" {\n  2710|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"title and parent_id required\"); return\n  2711|\t}\n  2712|\tparent, err := lib.EntryByID(h.DB, h.Cfg, body.ParentID)\n  2713|\tif err != nil || parent == nil {\n  2714|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_parent\", \"Parent entry not found\"); return\n  2715|\t}\n  2716|\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, body.ParentID)\n  2717|\ttitle := strings.TrimSpace(body.Title)\n  2718|\treqData := lib.RequestData{Title: title, Status: \"open\", Priority: \"medium\"}\n  2719|\treqDataJSON, _ := json.Marshal(reqData)\n  2720|\tentry := \u0026lib.Entry{\n  2721|\t\tProjectID:   projectID,\n  2722|\t\tParentID:    body.ParentID,\n  2723|\t\tType:        lib.TypeRequest,\n  2724|\t\tDepth:       parent.Depth + 1,\n  2725|\t\tSortOrder:   (len(siblings) + 1) * 100,\n  2726|\t\tSummaryText: title,\n  2727|\t\tDataText:    string(reqDataJSON),\n  2728|\t\tStage:       lib.StagePreDataroom,\n  2729|\t}\n  2730|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n  2731|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create request\"); return\n  2732|\t}\n  2733|\tJSONResponse(w, http.StatusOK, map[string]any{\"entry_id\": entry.EntryID, \"title\": title})\n  2734|}\n  2735|\n  2736|// CreateRequestList creates a new empty request list for a project.\n  2737|func (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) {\n  2738|\tactorID := UserIDFromContext(r.Context())\n  2739|\tprojectID := chi.URLParam(r, \"projectID\")\n  2740|\n  2741|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2742|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2743|\t\treturn\n  2744|\t}\n  2745|\n  2746|\tvar body struct {\n  2747|\t\tName string `json:\"name\"`\n  2748|\t}\n  2749|\tif err := json.NewDecoder(r.Body).Decode(\u0026body); err != nil || strings.TrimSpace(body.Name) == \"\" {\n  2750|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_body\", \"name is required\")\n  2751|\t\treturn\n  2752|\t}\n  2753|\n  2754|\tlistName := strings.TrimSpace(body.Name)\n  2755|\t// Sort after existing lists: use sibling count * 1000\n  2756|\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID)\n  2757|\tlistCount := 0\n  2758|\tfor _, s := range siblings {\n  2759|\t\tif s.Type == lib.TypeRequestList {\n  2760|\t\t\tlistCount++\n  2761|\t\t}\n  2762|\t}\n  2763|\trlData := lib.RequestListData{Name: listName}\n  2764|\trlDataJSON, _ := json.Marshal(rlData)\n  2765|\tentry := \u0026lib.Entry{\n  2766|\t\tProjectID:   projectID,\n  2767|\t\tParentID:    projectID,\n  2768|\t\tType:        lib.TypeRequestList,\n  2769|\t\tDepth:       1,\n  2770|\t\tSortOrder:   (listCount + 1) * 1000,\n  2771|\t\tSummaryText: listName,\n  2772|\t\tDataText:    string(rlDataJSON),\n  2773|\t\tStage:       lib.StagePreDataroom,\n  2774|\t}\n  2775|\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\n  2776|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create request list\")\n  2777|\t\treturn\n  2778|\t}\n  2779|\n  2780|\tJSONResponse(w, http.StatusOK, map[string]any{\n  2781|\t\t\"entry_id\": entry.EntryID,\n  2782|\t\t\"name\":     listName,\n  2783|\t})\n  2784|}\n  2785|\n  2786|func (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) {\n  2787|\tactorID := UserIDFromContext(r.Context())\n  2788|\tprojectID := chi.URLParam(r, \"projectID\")\n  2789|\n  2790|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  2791|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  2792|\t\treturn\n  2793|\t}\n  2794|\n  2795|\t// Parse multipart form (max 20MB)\n  2796|\tif err := r.ParseMultipartForm(20 \u003c\u003c 20); err != nil {\n  2797|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_form\", \"Failed to parse form data\")\n  2798|\t\treturn\n  2799|\t}\n  2800|\n  2801|\tfile, header, err := r.FormFile(\"file\")\n  2802|\tif err != nil {\n  2803|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_file\", \"File is required\")\n  2804|\t\treturn\n  2805|\t}\n  2806|\tdefer file.Close()\n  2807|\n  2808|\tmode := r.FormValue(\"mode\")\n  2809|\tif mode == \"\" {\n  2810|\t\tmode = \"add\"\n  2811|\t}\n  2812|\tsectionFilter := r.FormValue(\"section_filter\")\n  2813|\n  2814|\t// Read file into memory\n  2815|\traw, err := io.ReadAll(file)\n  2816|\tif err != nil {\n  2817|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to read file\")\n  2818|\t\treturn\n  2819|\t}\n  2820|\n  2821|\t// Detect file type by extension or magic bytes\n  2822|\tfname := strings.ToLower(header.Filename)\n  2823|\tisXLSX := strings.HasSuffix(fname, \".xlsx\") || strings.HasSuffix(fname, \".xls\") ||\n  2824|\t\t(len(raw) \u003e= 2 \u0026\u0026 raw[0] == 'P' \u0026\u0026 raw[1] == 'K')\n  2825|\n  2826|\tvar rows [][]string\n  2827|\n  2828|\tif isXLSX {\n  2829|\t\txf, err := excelize.OpenReader(bytes.NewReader(raw))\n  2830|\t\tif err != nil {\n  2831|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_xlsx\", \"Failed to parse XLSX: \"+err.Error())\n  2832|\t\t\treturn\n  2833|\t\t}\n  2834|\t\tsheetName := xf.GetSheetName(0)\n  2835|\t\txlRows, err := xf.GetRows(sheetName)\n  2836|\t\tif err != nil {\n  2837|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_xlsx\", \"Failed to read sheet: \"+err.Error())\n  2838|\t\t\treturn\n  2839|\t\t}\n  2840|\t\trows = xlRows\n  2841|\t} else {\n  2842|\t\treader := csv.NewReader(bufio.NewReader(bytes.NewReader(raw)))\n  2843|\t\treader.FieldsPerRecord = -1\n  2844|\t\treader.TrimLeadingSpace = true\n  2845|\t\tcsvRows, err := reader.ReadAll()\n  2846|\t\tif err != nil {\n  2847|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_csv\", \"Failed to parse CSV: \"+err.Error())\n  2848|\t\t\treturn\n  2849|\t\t}\n  2850|\t\trows = csvRows\n  2851|\t}\n  2852|\n  2853|\t// Log first 12 rows for debugging\n  2854|\tfor ri, row := range rows {\n  2855|\t\tif ri \u003e= 12 {\n  2856|\t\t\tbreak\n  2857|\t\t}\n  2858|\t\tlog.Printf(\"[import-debug] row %d: %v\", ri, row)\n  2859|\t}\n  2860|\n  2861|\t// Smart header detection: scan first 12 rows for keyword matches\n  2862|\tidxSection := -1\n  2863|\tidxItem := -1\n  2864|\tidxDesc := -1\n  2865|\tidxPriority := -1\n  2866|\theaderRowIdx := 0\n  2867|\tbestScore := 0\n  2868|\n  2869|\tfor ri, record := range rows {\n  2870|\t\tif ri \u003e= 12 {\n  2871|\t\t\tbreak\n  2872|\t\t}\n  2873|\t\tscore := 0\n  2874|\t\ttmpSection, tmpItem, tmpDesc, tmpPri := -1, -1, -1, -1\n  2875|\t\tfor ci, cell := range record {\n  2876|\t\t\th := strings.ToLower(strings.TrimSpace(cell))\n  2877|\t\t\tif h == \"\" {\n  2878|\t\t\t\tcontinue\n  2879|\t\t\t}\n  2880|\t\t\tif containsAny(h, \"section\", \"category\", \"topic\", \"area\", \"phase\", \"workstream\") {\n  2881|\t\t\t\ttmpSection = ci\n  2882|\t\t\t\tscore += 3\n  2883|\t\t\t} else if containsAny(h, \"description\", \"request\", \"document\", \"information requested\", \"detail\") {\n  2884|\t\t\t\ttmpDesc = ci\n  2885|\t\t\t\tscore += 3\n  2886|\t\t\t} else if containsAny(h, \"priority\", \"urgency\", \"importance\", \"criticality\") {\n  2887|\t\t\t\ttmpPri = ci\n  2888|\t\t\t\tscore += 2\n  2889|\t\t\t} else if h == \"#\" || h == \"no.\" || h == \"no\" || h == \"item #\" || h == \"item#\" ||\n  2890|\t\t\t\tcontainsAny(h, \"item no\", \"ref no\", \"ref #\") {\n  2891|\t\t\t\ttmpItem = ci\n  2892|\t\t\t\tscore += 2\n  2893|\t\t\t}\n  2894|\t\t}\n  2895|\t\tif score \u003e bestScore {\n  2896|\t\t\tbestScore = score\n  2897|\t\t\theaderRowIdx = ri\n  2898|\t\t\tif tmpSection \u003e= 0 {\n  2899|\t\t\t\tidxSection = tmpSection\n  2900|\t\t\t}\n  2901|\t\t\tif tmpItem \u003e= 0 {\n  2902|\t\t\t\tidxItem = tmpItem\n  2903|\t\t\t}\n  2904|\t\t\tif tmpDesc \u003e= 0 {\n  2905|\t\t\t\tidxDesc = tmpDesc\n  2906|\t\t\t}\n  2907|\t\t\tif tmpPri \u003e= 0 {\n  2908|\t\t\t\tidxPriority = tmpPri\n  2909|\t\t\t}\n  2910|\t\t}\n  2911|\t}\n  2912|\n  2913|\t// Fall back to positional if no header found\n  2914|\tif bestScore \u003c 2 {\n  2915|\t\theaderRowIdx = 0\n  2916|\t\tidxSection = 0\n  2917|\t\tidxItem = 1\n  2918|\t\tidxDesc = 2\n  2919|\t}\n  2920|\n  2921|\t// If desc still not found, pick column with longest average text\n  2922|\tif idxDesc \u003c 0 \u0026\u0026 len(rows) \u003e headerRowIdx+1 {\n  2923|\t\tmaxLen := 0\n  2924|\t\tfor ci := range rows[headerRowIdx] {\n  2925|\t\t\ttotal := 0\n  2926|\t\t\tcount := 0\n  2927|\t\t\tfor ri := headerRowIdx + 1; ri \u003c len(rows) \u0026\u0026 ri \u003c headerRowIdx+20; ri++ {\n  2928|\t\t\t\tif ci \u003c len(rows[ri]) {\n  2929|\t\t\t\t\ttotal += len(strings.TrimSpace(rows[ri][ci]))\n  2930|\t\t\t\t\tcount++\n  2931|\t\t\t\t}\n  2932|\t\t\t}\n  2933|\t\t\tavg := 0\n  2934|\t\t\tif count \u003e 0 {\n  2935|\t\t\t\tavg = total / count\n  2936|\t\t\t}\n  2937|\t\t\tif avg \u003e maxLen \u0026\u0026 ci != idxSection \u0026\u0026 ci != idxItem {\n  2938|\t\t\t\tmaxLen = avg\n  2939|\t\t\t\tidxDesc = ci\n  2940|\t\t\t}\n  2941|\t\t}\n  2942|\t}\n  2943|\n  2944|\tlog.Printf(\"[import-debug] header at row %d (score=%d) | section=%d item=%d desc=%d priority=%d\",\n  2945|\t\theaderRowIdx, bestScore, idxSection, idxItem, idxDesc, idxPriority)\n  2946|\n  2947|\t// Parse rows into request items\n  2948|\ttype reqRow struct {\n  2949|\t\tsection, itemNumber, description, priority string\n  2950|\t}\n  2951|\tvar items []reqRow\n  2952|\tsections := make(map[string]bool)\n  2953|\n  2954|\tfor ri, record := range rows {\n  2955|\t\tif ri \u003c= headerRowIdx {\n  2956|\t\t\tcontinue\n  2957|\t\t}\n  2958|\t\tif len(record) == 0 {\n  2959|\t\t\tcontinue\n  2960|\t\t}\n  2961|\t\t// Skip blank rows\n  2962|\t\tallBlank := true\n  2963|\t\tfor _, c := range record {\n  2964|\t\t\tif strings.TrimSpace(c) != \"\" {\n  2965|\t\t\t\tallBlank = false\n  2966|\t\t\t\tbreak\n  2967|\t\t\t}\n  2968|\t\t}\n  2969|\t\tif allBlank {\n  2970|\t\t\tcontinue\n  2971|\t\t}\n  2972|\n  2973|\t\tget := func(idx int) string {\n  2974|\t\t\tif idx \u003e= 0 \u0026\u0026 idx \u003c len(record) {\n  2975|\t\t\t\treturn strings.TrimSpace(record[idx])\n  2976|\t\t\t}\n  2977|\t\t\treturn \"\"\n  2978|\t\t}\n  2979|\n  2980|\t\tdesc := get(idxDesc)\n  2981|\t\tif desc == \"\" {\n  2982|\t\t\tcontinue\n  2983|\t\t}\n  2984|\n  2985|\t\tsection := get(idxSection)\n  2986|\t\tif sectionFilter != \"\" \u0026\u0026 !strings.EqualFold(section, sectionFilter) {\n  2987|\t\t\tcontinue\n  2988|\t\t}\n  2989|\n  2990|\t\tpriority := \"medium\"\n  2991|\t\tif idxPriority \u003e= 0 {\n  2992|\t\t\tp := strings.ToLower(get(idxPriority))\n  2993|\t\t\tswitch {\n  2994|\t\t\tcase strings.Contains(p, \"high\") || strings.Contains(p, \"critical\") || strings.Contains(p, \"urgent\"):\n  2995|\t\t\t\tpriority = \"high\"\n  2996|\t\t\tcase strings.Contains(p, \"low\") || strings.Contains(p, \"nice\") || strings.Contains(p, \"optional\"):\n  2997|\t\t\t\tpriority = \"low\"\n  2998|\t\t\t}\n  2999|\t\t}\n  3000|\n  3001|\t\titems = append(items, reqRow{\n  3002|\t\t\tsection:     section,\n  3003|\t\t\titemNumber:  get(idxItem),\n  3004|\t\t\tdescription: desc,\n  3005|\t\t\tpriority:    priority,\n  3006|\t\t})\n  3007|\t\tsections[section] = true\n  3008|\t}\n  3009|\n  3010|\tif len(items) == 0 {\n  3011|\t\tErrorResponse(w, http.StatusBadRequest, \"no_items\", \"No valid items found in file\")\n  3012|\t\treturn\n  3013|\t}\n  3014|\n  3015|\t// Handle replace mode: soft-delete existing request_lists and their subtrees\n  3016|\tif mode == \"replace\" {\n  3017|\t\t// Find all request_list entries for this project\n  3018|\t\trlRows, err := h.DB.Conn.Query(\n  3019|\t\t\t`SELECT entry_id FROM entries WHERE project_id = ? AND type IN ('request_list','section','request') AND deleted_at IS NULL`,\n  3020|\t\t\tprojectID,\n  3021|\t\t)\n  3022|\t\tif err == nil {\n  3023|\t\t\tdefer rlRows.Close()\n  3024|\t\t\tnow := time.Now().UnixMilli()\n  3025|\t\t\tfor rlRows.Next() {\n  3026|\t\t\t\tvar eid string\n  3027|\t\t\t\trlRows.Scan(\u0026eid)\n  3028|\t\t\t\th.DB.Conn.Exec(\n  3029|\t\t\t\t\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\n  3030|\t\t\t\t\tnow, actorID, now, eid,\n  3031|\t\t\t\t)\n  3032|\t\t\t}\n  3033|\t\t}\n  3034|\t}\n  3035|\n  3036|\t// Derive project key for encryption\n  3037|\tprojectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3038|\tif err != nil {\n  3039|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n  3040|\t\treturn\n  3041|\t}\n  3042|\n  3043|\tnow := time.Now().UnixMilli()\n  3044|\timported := 0\n  3045|\tskipped := 0\n  3046|\n  3047|\t// Get list_name from form (defaults to filename without extension)\n  3048|\tlistName := r.FormValue(\"list_name\")\n  3049|\tif listName == \"\" {\n  3050|\t\tlistName = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))\n  3051|\t}\n  3052|\n  3053|\t// 1. Create request_list entry (depth=1, parent=project)\n  3054|\trequestListID := uuid.New().String()\n  3055|\tvisibilityOrgID := r.FormValue(\"visibility_org_id\")\n  3056|\trlData := lib.RequestListData{Name: listName, VisibilityOrgID: visibilityOrgID}\n  3057|\trlDataJSON, _ := json.Marshal(rlData)\n  3058|\trlSummaryPacked, _ := lib.Pack(projectKey, listName)\n  3059|\trlDataPacked, _ := lib.Pack(projectKey, string(rlDataJSON))\n  3060|\n  3061|\tvar importMaxSort int\n  3062|\th.DB.Conn.QueryRow(\n  3063|\t\t`SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE project_id=? AND type='request_list' AND deleted_at IS NULL`, projectID,\n  3064|\t).Scan(\u0026importMaxSort)\n  3065|\trlSortOrder := importMaxSort + 1000\n  3066|\n  3067|\th.DB.Conn.Exec(\n  3068|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  3069|\t\t  search_key, search_key2, summary, data, stage,\n  3070|\t\t  assignee_id, return_to_id, origin_id,\n  3071|\t\t  version, deleted_at, deleted_by, key_version,\n  3072|\t\t  created_at, updated_at, created_by)\n  3073|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  3074|\t\trequestListID, projectID, projectID, lib.TypeRequestList, 1, rlSortOrder,\n  3075|\t\tnil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom,\n  3076|\t\t\"\", \"\", \"\",\n  3077|\t\t1, nil, nil, 1,\n  3078|\t\tnow, now, actorID,\n  3079|\t)\n  3080|\n  3081|\t// 2. Group items by section and create section + request entries\n  3082|\t// Preserve section order from the file\n  3083|\tvar sectionOrder []string\n  3084|\tsectionSeen := map[string]bool{}\n  3085|\tsectionItems := map[string][]reqRow{}\n  3086|\tfor _, item := range items {\n  3087|\t\tsec := item.section\n  3088|\t\tif sec == \"\" {\n  3089|\t\t\tsec = \"General\"\n  3090|\t\t}\n  3091|\t\tif !sectionSeen[sec] {\n  3092|\t\t\tsectionSeen[sec] = true\n  3093|\t\t\tsectionOrder = append(sectionOrder, sec)\n  3094|\t\t}\n  3095|\t\tsectionItems[sec] = append(sectionItems[sec], item)\n  3096|\t}\n  3097|\n  3098|\tfor secIdx, secName := range sectionOrder {\n  3099|\t\t// Create section entry (depth=2, parent=request_list)\n  3100|\t\tsectionID := uuid.New().String()\n  3101|\t\tsecData := lib.SectionData{Name: secName}\n  3102|\t\tsecDataJSON, _ := json.Marshal(secData)\n  3103|\t\tsecSummaryPacked, _ := lib.Pack(projectKey, secName)\n  3104|\t\tsecDataPacked, _ := lib.Pack(projectKey, string(secDataJSON))\n  3105|\n  3106|\t\th.DB.Conn.Exec(\n  3107|\t\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  3108|\t\t\t  search_key, search_key2, summary, data, stage,\n  3109|\t\t\t  assignee_id, return_to_id, origin_id,\n  3110|\t\t\t  version, deleted_at, deleted_by, key_version,\n  3111|\t\t\t  created_at, updated_at, created_by)\n  3112|\t\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  3113|\t\t\tsectionID, projectID, requestListID, lib.TypeSection, 2, (secIdx+1)*1000,\n  3114|\t\t\tnil, nil, secSummaryPacked, secDataPacked, lib.StagePreDataroom,\n  3115|\t\t\t\"\", \"\", \"\",\n  3116|\t\t\t1, nil, nil, 1,\n  3117|\t\t\tnow, now, actorID,\n  3118|\t\t)\n  3119|\n  3120|\t\t// Create request entries under this section (depth=3)\n  3121|\t\tfor reqIdx, item := range sectionItems[secName] {\n  3122|\t\t\tentryID := uuid.New().String()\n  3123|\n  3124|\t\t\tsummary := item.description\n  3125|\t\t\tif len(summary) \u003e 120 {\n  3126|\t\t\t\tsummary = summary[:120]\n  3127|\t\t\t}\n  3128|\n  3129|\t\t\t// Normalize priority\n  3130|\t\t\tpriority := item.priority\n  3131|\t\t\tswitch strings.ToLower(priority) {\n  3132|\t\t\tcase \"critical\", \"urgent\":\n  3133|\t\t\t\tpriority = \"critical\"\n  3134|\t\t\tcase \"high\":\n  3135|\t\t\t\tpriority = \"high\"\n  3136|\t\t\tcase \"low\", \"optional\", \"nice\":\n  3137|\t\t\t\tpriority = \"low\"\n  3138|\t\t\tdefault:\n  3139|\t\t\t\tpriority = \"medium\"\n  3140|\t\t\t}\n  3141|\n  3142|\t\t\treqData := lib.RequestData{\n  3143|\t\t\t\tTitle:       item.description,\n  3144|\t\t\t\tItemNumber:  item.itemNumber,\n  3145|\t\t\t\tSection:     secName,\n  3146|\t\t\t\tDescription: item.description,\n  3147|\t\t\t\tPriority:    priority,\n  3148|\t\t\t\tStatus:      \"open\",\n  3149|\t\t\t}\n  3150|\t\t\tdataJSON, _ := json.Marshal(reqData)\n  3151|\n  3152|\t\t\tsummaryPacked, err := lib.Pack(projectKey, summary)\n  3153|\t\t\tif err != nil {\n  3154|\t\t\t\tskipped++\n  3155|\t\t\t\tcontinue\n  3156|\t\t\t}\n  3157|\t\t\tdataPacked, err := lib.Pack(projectKey, string(dataJSON))\n  3158|\t\t\tif err != nil {\n  3159|\t\t\t\tskipped++\n  3160|\t\t\t\tcontinue\n  3161|\t\t\t}\n  3162|\n  3163|\t\t\t_, err = h.DB.Conn.Exec(\n  3164|\t\t\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  3165|\t\t\t\t  search_key, search_key2, summary, data, stage,\n  3166|\t\t\t\t  assignee_id, return_to_id, origin_id,\n  3167|\t\t\t\t  version, deleted_at, deleted_by, key_version,\n  3168|\t\t\t\t  created_at, updated_at, created_by)\n  3169|\t\t\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  3170|\t\t\t\tentryID, projectID, sectionID, lib.TypeRequest, 3, (reqIdx+1)*1000,\n  3171|\t\t\t\tnil, nil, summaryPacked, dataPacked, lib.StagePreDataroom,\n  3172|\t\t\t\t\"\", \"\", \"\",\n  3173|\t\t\t\t1, nil, nil, 1,\n  3174|\t\t\t\tnow, now, actorID,\n  3175|\t\t\t)\n  3176|\t\t\tif err != nil {\n  3177|\t\t\t\tlog.Printf(\"Failed to insert request: %v\", err)\n  3178|\t\t\t\tskipped++\n  3179|\t\t\t\tcontinue\n  3180|\t\t\t}\n  3181|\t\t\timported++\n  3182|\t\t}\n  3183|\t}\n  3184|\n  3185|\tlog.Printf(\"[import] list=%q, total rows: %d, header row: %d, imported: %d, skipped: %d, sections: %v\",\n  3186|\t\tlistName, len(rows), headerRowIdx, imported, skipped, sectionOrder)\n  3187|\n  3188|\tJSONResponse(w, http.StatusOK, map[string]any{\n  3189|\t\t\"imported\":        imported,\n  3190|\t\t\"skipped\":         skipped,\n  3191|\t\t\"sections\":        sectionOrder,\n  3192|\t\t\"request_list_id\": requestListID,\n  3193|\t})\n  3194|}\n  3195|\n  3196|// UpdateRequestListVisibility handles PATCH /api/projects/{projectID}/entries/{entryID}/visibility\n  3197|// Sets or clears the visibility_org_id on a request_list entry. IB roles only.\n  3198|func (h *Handlers) UpdateRequestListVisibility(w http.ResponseWriter, r *http.Request) {\n  3199|\tactorID := UserIDFromContext(r.Context())\n  3200|\tprojectID := chi.URLParam(r, \"projectID\")\n  3201|\tentryID := chi.URLParam(r, \"entryID\")\n  3202|\n  3203|\t// Only IB roles can change visibility\n  3204|\trole, err := lib.GetUserHighestRole(h.DB, actorID, projectID)\n  3205|\tif err != nil || (role != lib.RoleIBAdmin \u0026\u0026 role != lib.RoleIBMember) {\n  3206|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Only IB roles can change list visibility\")\n  3207|\t\treturn\n  3208|\t}\n  3209|\n  3210|\tvar req struct {\n  3211|\t\tVisibilityOrgID *string `json:\"visibility_org_id\"` // null to clear\n  3212|\t}\n  3213|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  3214|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  3215|\t\treturn\n  3216|\t}\n  3217|\n  3218|\t// Read the entry and update its data\n  3219|\tentry, err := lib.EntryByID(h.DB, h.Cfg, entryID)\n  3220|\tif err != nil || entry == nil || entry.Type != lib.TypeRequestList || entry.ProjectID != projectID {\n  3221|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Request list not found\")\n  3222|\t\treturn\n  3223|\t}\n  3224|\n  3225|\tvar rlData lib.RequestListData\n  3226|\tif entry.DataText != \"\" {\n  3227|\t\tjson.Unmarshal([]byte(entry.DataText), \u0026rlData)\n  3228|\t}\n  3229|\n  3230|\tif req.VisibilityOrgID != nil {\n  3231|\t\trlData.VisibilityOrgID = *req.VisibilityOrgID\n  3232|\t} else {\n  3233|\t\trlData.VisibilityOrgID = \"\"\n  3234|\t}\n  3235|\n  3236|\tdataJSON, _ := json.Marshal(rlData)\n  3237|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3238|\tdataPacked, _ := lib.Pack(projectKey, string(dataJSON))\n  3239|\n  3240|\tnow := time.Now().UnixMilli()\n  3241|\t_, err = h.DB.Conn.Exec(\n  3242|\t\t`UPDATE entries SET data = ?, updated_at = ?, version = version + 1 WHERE entry_id = ?`,\n  3243|\t\tdataPacked, now, entryID,\n  3244|\t)\n  3245|\tif err != nil {\n  3246|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to update visibility\")\n  3247|\t\treturn\n  3248|\t}\n  3249|\n  3250|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"updated\"})\n  3251|}\n  3252|\n  3253|// ---------------------------------------------------------------------------\n  3254|// Tree API endpoints\n  3255|// ---------------------------------------------------------------------------\n  3256|\n  3257|// ListRequestTree handles GET /api/projects/{projectID}/requests/tree\n  3258|// Returns all request_list, section, and request entries in DFS tree order.\n  3259|func (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {\n  3260|\tactorID := UserIDFromContext(r.Context())\n  3261|\tprojectID := chi.URLParam(r, \"projectID\")\n  3262|\n  3263|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n  3264|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3265|\t\treturn\n  3266|\t}\n  3267|\n  3268|\t// Get actor's role and org for visibility filtering\n  3269|\tactorRole, _ := lib.GetUserHighestRole(h.DB, actorID, projectID)\n  3270|\tactorUser, _ := lib.UserByID(h.DB, actorID)\n  3271|\tactorOrgID := \"\"\n  3272|\tif actorUser != nil {\n  3273|\t\tactorOrgID = actorUser.OrgID\n  3274|\t}\n  3275|\tisIBRole := actorRole == lib.RoleIBAdmin || actorRole == lib.RoleIBMember\n  3276|\n  3277|\trows, err := h.DB.Conn.Query(\n  3278|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n  3279|\t\t        search_key, search_key2, summary, data, stage,\n  3280|\t\t        assignee_id, return_to_id, origin_id,\n  3281|\t\t        version, deleted_at, deleted_by, key_version,\n  3282|\t\t        created_at, updated_at, created_by\n  3283|\t\t FROM entries\n  3284|\t\t WHERE project_id = ? AND type IN ('request_list','section','request') AND deleted_at IS NULL\n  3285|\t\t ORDER BY sort_order ASC, created_at ASC`, projectID,\n  3286|\t)\n  3287|\tif err != nil {\n  3288|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list request tree\")\n  3289|\t\treturn\n  3290|\t}\n  3291|\tdefer rows.Close()\n  3292|\n  3293|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3294|\n  3295|\t// Collect all entries\n  3296|\ttype treeEntry struct {\n  3297|\t\tEntryID   string `json:\"entry_id\"`\n  3298|\t\tParentID  string `json:\"parent_id\"`\n  3299|\t\tType      string `json:\"type\"`\n  3300|\t\tDepth     int    `json:\"depth\"`\n  3301|\t\tSortOrder int    `json:\"sort_order\"`\n  3302|\t\tVersion   int    `json:\"version\"`\n  3303|\t\tCreatedAt int64  `json:\"created_at\"`\n  3304|\t\tData      any    `json:\"data\"`\n  3305|\t}\n  3306|\n  3307|\tvar all []treeEntry\n  3308|\tbyID := map[string]*treeEntry{}\n  3309|\tchildrenOf := map[string][]string{} // parent_id → [entry_ids]\n  3310|\n  3311|\tfor rows.Next() {\n  3312|\t\tvar e lib.Entry\n  3313|\t\terr := rows.Scan(\n  3314|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  3315|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  3316|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  3317|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  3318|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  3319|\t\t)\n  3320|\t\tif err != nil {\n  3321|\t\t\tcontinue\n  3322|\t\t}\n  3323|\n  3324|\t\tte := treeEntry{\n  3325|\t\t\tEntryID:   e.EntryID,\n  3326|\t\t\tParentID:  e.ParentID,\n  3327|\t\t\tType:      e.Type,\n  3328|\t\t\tDepth:     e.Depth,\n  3329|\t\t\tSortOrder: e.SortOrder,\n  3330|\t\t\tVersion:   e.Version,\n  3331|\t\t\tCreatedAt: e.CreatedAt,\n  3332|\t\t}\n  3333|\n  3334|\t\t// Decrypt data\n  3335|\t\tif len(e.Data) \u003e 0 {\n  3336|\t\t\tdataText, err := lib.Unpack(projectKey, e.Data)\n  3337|\t\t\tif err == nil {\n  3338|\t\t\t\tvar parsed any\n  3339|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026parsed) == nil {\n  3340|\t\t\t\t\tte.Data = parsed\n  3341|\t\t\t\t}\n  3342|\t\t\t}\n  3343|\t\t}\n  3344|\n  3345|\t\tall = append(all, te)\n  3346|\t\tbyID[te.EntryID] = \u0026all[len(all)-1]\n  3347|\t\tchildrenOf[te.ParentID] = append(childrenOf[te.ParentID], te.EntryID)\n  3348|\t}\n  3349|\n  3350|\t// Build set of hidden request_list IDs based on visibility\n  3351|\thiddenLists := map[string]bool{}\n  3352|\tif !isIBRole {\n  3353|\t\tfor _, te := range all {\n  3354|\t\t\tif te.Type == lib.TypeRequestList {\n  3355|\t\t\t\tif dataMap, ok := te.Data.(map[string]any); ok {\n  3356|\t\t\t\t\tif visOrgID, ok := dataMap[\"visibility_org_id\"].(string); ok \u0026\u0026 visOrgID != \"\" {\n  3357|\t\t\t\t\t\tif visOrgID != actorOrgID {\n  3358|\t\t\t\t\t\t\thiddenLists[te.EntryID] = true\n  3359|\t\t\t\t\t\t}\n  3360|\t\t\t\t\t}\n  3361|\t\t\t\t}\n  3362|\t\t\t}\n  3363|\t\t}\n  3364|\t}\n  3365|\n  3366|\t// Get answer link counts\n  3367|\tlinkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID)\n  3368|\n  3369|\t// DFS to produce tree order\n  3370|\ttype treeItem struct {\n  3371|\t\tEntryID       string `json:\"entry_id\"`\n  3372|\t\tParentID      string `json:\"parent_id\"`\n  3373|\t\tType          string `json:\"type\"`\n  3374|\t\tDepth         int    `json:\"depth\"`\n  3375|\t\tSortOrder     int    `json:\"sort_order\"`\n  3376|\t\tVersion       int    `json:\"version\"`\n  3377|\t\tCreatedAt     int64  `json:\"created_at\"`\n  3378|\t\tData          any    `json:\"data\"`\n  3379|\t\tAnswerCount   int    `json:\"answer_count\"`\n  3380|\t\tChildrenCount int    `json:\"children_count\"`\n  3381|\t}\n  3382|\n  3383|\tvar result []treeItem\n  3384|\tvar dfs func(parentID string)\n  3385|\tdfs = func(parentID string) {\n  3386|\t\tchildren := childrenOf[parentID]\n  3387|\t\tfor _, cid := range children {\n  3388|\t\t\tte := byID[cid]\n  3389|\t\t\tif te == nil {\n  3390|\t\t\t\tcontinue\n  3391|\t\t\t}\n  3392|\t\t\t// Skip hidden request_lists and their subtrees\n  3393|\t\t\tif hiddenLists[te.EntryID] {\n  3394|\t\t\t\tcontinue\n  3395|\t\t\t}\n  3396|\t\t\titem := treeItem{\n  3397|\t\t\t\tEntryID:       te.EntryID,\n  3398|\t\t\t\tParentID:      te.ParentID,\n  3399|\t\t\t\tType:          te.Type,\n  3400|\t\t\t\tDepth:         te.Depth,\n  3401|\t\t\t\tSortOrder:     te.SortOrder,\n  3402|\t\t\t\tVersion:       te.Version,\n  3403|\t\t\t\tCreatedAt:     te.CreatedAt,\n  3404|\t\t\t\tData:          te.Data,\n  3405|\t\t\t\tAnswerCount:   linkCounts[te.EntryID],\n  3406|\t\t\t\tChildrenCount: len(childrenOf[te.EntryID]),\n  3407|\t\t\t}\n  3408|\t\t\tresult = append(result, item)\n  3409|\t\t\tdfs(te.EntryID)\n  3410|\t\t}\n  3411|\t}\n  3412|\n  3413|\t// Start DFS from project (request_lists have parent=projectID)\n  3414|\tdfs(projectID)\n  3415|\n  3416|\tif result == nil {\n  3417|\t\tresult = []treeItem{}\n  3418|\t}\n  3419|\n  3420|\tJSONResponse(w, http.StatusOK, result)\n  3421|}\n  3422|\n  3423|// MoveEntry handles POST /api/projects/{projectID}/entries/{entryID}/move\n  3424|// Body: {\"parent_id\": \"...\", \"position\": 0}\n  3425|func (h *Handlers) MoveEntry(w http.ResponseWriter, r *http.Request) {\n  3426|\tactorID := UserIDFromContext(r.Context())\n  3427|\tprojectID := chi.URLParam(r, \"projectID\")\n  3428|\tentryID := chi.URLParam(r, \"entryID\")\n  3429|\n  3430|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  3431|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3432|\t\treturn\n  3433|\t}\n  3434|\n  3435|\tvar req struct {\n  3436|\t\tParentID string `json:\"parent_id\"`\n  3437|\t\tPosition int    `json:\"position\"` // 0-based position among siblings\n  3438|\t}\n  3439|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  3440|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  3441|\t\treturn\n  3442|\t}\n  3443|\n  3444|\t// Get the entry being moved\n  3445|\tentry, err := lib.EntryByID(h.DB, h.Cfg, entryID)\n  3446|\tif err != nil || entry == nil {\n  3447|\t\tErrorResponse(w, http.StatusNotFound, \"not_found\", \"Entry not found\")\n  3448|\t\treturn\n  3449|\t}\n  3450|\tif entry.ProjectID != projectID {\n  3451|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Entry not in project\")\n  3452|\t\treturn\n  3453|\t}\n  3454|\n  3455|\t// Get new parent to determine depth\n  3456|\tnewDepth := 1\n  3457|\tif req.ParentID != projectID {\n  3458|\t\tparent, err := lib.EntryByID(h.DB, h.Cfg, req.ParentID)\n  3459|\t\tif err != nil || parent == nil {\n  3460|\t\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_parent\", \"Parent not found\")\n  3461|\t\t\treturn\n  3462|\t\t}\n  3463|\t\tnewDepth = parent.Depth + 1\n  3464|\t}\n  3465|\n  3466|\toldParentID := entry.ParentID\n  3467|\n  3468|\t// Assign sort_order: position * 1000\n  3469|\tsortOrder := (req.Position + 1) * 1000\n  3470|\n  3471|\t// Move the entry\n  3472|\tif err := lib.EntryMoveSort(h.DB, entryID, req.ParentID, newDepth, sortOrder); err != nil {\n  3473|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to move entry\")\n  3474|\t\treturn\n  3475|\t}\n  3476|\n  3477|\t// Renumber siblings at new parent\n  3478|\tlib.RenumberSiblings(h.DB, req.ParentID)\n  3479|\n  3480|\t// Renumber siblings at old parent if different\n  3481|\tif oldParentID != req.ParentID {\n  3482|\t\tlib.RenumberSiblings(h.DB, oldParentID)\n  3483|\t}\n  3484|\n  3485|\t// Update depth of all descendants recursively\n  3486|\th.updateDescendantDepths(entryID, newDepth)\n  3487|\n  3488|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"moved\"})\n  3489|}\n  3490|\n  3491|// updateDescendantDepths recursively updates depth of children.\n  3492|func (h *Handlers) updateDescendantDepths(parentID string, parentDepth int) {\n  3493|\trows, err := h.DB.Conn.Query(\n  3494|\t\t`SELECT entry_id FROM entries WHERE parent_id = ? AND deleted_at IS NULL`, parentID,\n  3495|\t)\n  3496|\tif err != nil {\n  3497|\t\treturn\n  3498|\t}\n  3499|\tdefer rows.Close()\n  3500|\n  3501|\tvar ids []string\n  3502|\tfor rows.Next() {\n  3503|\t\tvar id string\n  3504|\t\trows.Scan(\u0026id)\n  3505|\t\tids = append(ids, id)\n  3506|\t}\n  3507|\n  3508|\tchildDepth := parentDepth + 1\n  3509|\tfor _, id := range ids {\n  3510|\t\th.DB.Conn.Exec(`UPDATE entries SET depth = ? WHERE entry_id = ?`, childDepth, id)\n  3511|\t\th.updateDescendantDepths(id, childDepth)\n  3512|\t}\n  3513|}\n  3514|\n  3515|// ListAnswerLinks handles GET /api/projects/{projectID}/requests/{requestID}/links\n  3516|func (h *Handlers) ListAnswerLinks(w http.ResponseWriter, r *http.Request) {\n  3517|\tactorID := UserIDFromContext(r.Context())\n  3518|\tprojectID := chi.URLParam(r, \"projectID\")\n  3519|\trequestID := chi.URLParam(r, \"requestID\")\n  3520|\n  3521|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n  3522|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3523|\t\treturn\n  3524|\t}\n  3525|\n  3526|\tlinks, err := lib.AnswerLinksByRequest(h.DB, requestID)\n  3527|\tif err != nil {\n  3528|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list answer links\")\n  3529|\t\treturn\n  3530|\t}\n  3531|\n  3532|\t// Enrich with answer entry data\n  3533|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3534|\n  3535|\ttype LinkItem struct {\n  3536|\t\tAnswerID  string `json:\"answer_id\"`\n  3537|\t\tRequestID string `json:\"request_id\"`\n  3538|\t\tLinkedAt  int64  `json:\"linked_at\"`\n  3539|\t\tStatus    string `json:\"status\"`\n  3540|\t\tData      any    `json:\"data\"`\n  3541|\t}\n  3542|\n  3543|\tvar result []LinkItem\n  3544|\tfor _, l := range links {\n  3545|\t\titem := LinkItem{\n  3546|\t\t\tAnswerID:  l.AnswerID,\n  3547|\t\t\tRequestID: l.RequestID,\n  3548|\t\t\tLinkedAt:  l.LinkedAt,\n  3549|\t\t\tStatus:    l.Status,\n  3550|\t\t}\n  3551|\t\t// Get answer entry data\n  3552|\t\tanswerEntry, err := lib.EntryByID(h.DB, h.Cfg, l.AnswerID)\n  3553|\t\tif err == nil \u0026\u0026 answerEntry != nil \u0026\u0026 answerEntry.DataText != \"\" {\n  3554|\t\t\tvar parsed any\n  3555|\t\t\tif json.Unmarshal([]byte(answerEntry.DataText), \u0026parsed) == nil {\n  3556|\t\t\t\titem.Data = parsed\n  3557|\t\t\t}\n  3558|\t\t} else if err == nil \u0026\u0026 answerEntry != nil \u0026\u0026 len(answerEntry.Data) \u003e 0 {\n  3559|\t\t\tdataText, err := lib.Unpack(projectKey, answerEntry.Data)\n  3560|\t\t\tif err == nil {\n  3561|\t\t\t\tvar parsed any\n  3562|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026parsed) == nil {\n  3563|\t\t\t\t\titem.Data = parsed\n  3564|\t\t\t\t}\n  3565|\t\t\t}\n  3566|\t\t}\n  3567|\t\tresult = append(result, item)\n  3568|\t}\n  3569|\n  3570|\tif result == nil {\n  3571|\t\tresult = []LinkItem{}\n  3572|\t}\n  3573|\tJSONResponse(w, http.StatusOK, result)\n  3574|}\n  3575|\n  3576|// CreateAnswerLink handles POST /api/projects/{projectID}/requests/{requestID}/links\n  3577|func (h *Handlers) CreateAnswerLink(w http.ResponseWriter, r *http.Request) {\n  3578|\tactorID := UserIDFromContext(r.Context())\n  3579|\tprojectID := chi.URLParam(r, \"projectID\")\n  3580|\trequestID := chi.URLParam(r, \"requestID\")\n  3581|\n  3582|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  3583|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3584|\t\treturn\n  3585|\t}\n  3586|\n  3587|\tvar req struct {\n  3588|\t\tAnswerID string `json:\"answer_id\"`\n  3589|\t}\n  3590|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil || req.AnswerID == \"\" {\n  3591|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"answer_id required\")\n  3592|\t\treturn\n  3593|\t}\n  3594|\n  3595|\tif err := lib.AnswerLinkCreate(h.DB, req.AnswerID, requestID, actorID); err != nil {\n  3596|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create link\")\n  3597|\t\treturn\n  3598|\t}\n  3599|\n  3600|\tJSONResponse(w, http.StatusCreated, map[string]string{\"status\": \"linked\"})\n  3601|}\n  3602|\n  3603|// DeleteAnswerLink handles DELETE /api/projects/{projectID}/requests/{requestID}/links/{answerID}\n  3604|func (h *Handlers) DeleteAnswerLink(w http.ResponseWriter, r *http.Request) {\n  3605|\tactorID := UserIDFromContext(r.Context())\n  3606|\tprojectID := chi.URLParam(r, \"projectID\")\n  3607|\trequestID := chi.URLParam(r, \"requestID\")\n  3608|\tanswerID := chi.URLParam(r, \"answerID\")\n  3609|\n  3610|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  3611|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3612|\t\treturn\n  3613|\t}\n  3614|\n  3615|\tif err := lib.AnswerLinkDelete(h.DB, answerID, requestID); err != nil {\n  3616|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to delete link\")\n  3617|\t\treturn\n  3618|\t}\n  3619|\n  3620|\tJSONResponse(w, http.StatusOK, map[string]string{\"status\": \"unlinked\"})\n  3621|\t_ = actorID // actorID checked above\n  3622|}\n  3623|\n  3624|// ListAnswers handles GET /api/projects/{projectID}/answers\n  3625|// Returns all answer entries for the project, for use in the link picker.\n  3626|func (h *Handlers) ListAnswers(w http.ResponseWriter, r *http.Request) {\n  3627|\tactorID := UserIDFromContext(r.Context())\n  3628|\tprojectID := chi.URLParam(r, \"projectID\")\n  3629|\n  3630|\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \"\"); err != nil {\n  3631|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3632|\t\treturn\n  3633|\t}\n  3634|\n  3635|\tq := r.URL.Query().Get(\"q\")\n  3636|\n  3637|\trows, err := h.DB.Conn.Query(\n  3638|\t\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\n  3639|\t\t        search_key, search_key2, summary, data, stage,\n  3640|\t\t        assignee_id, return_to_id, origin_id,\n  3641|\t\t        version, deleted_at, deleted_by, key_version,\n  3642|\t\t        created_at, updated_at, created_by\n  3643|\t\t FROM entries WHERE project_id = ? AND type = 'answer' AND deleted_at IS NULL\n  3644|\t\t ORDER BY created_at DESC`, projectID,\n  3645|\t)\n  3646|\tif err != nil {\n  3647|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to list answers\")\n  3648|\t\treturn\n  3649|\t}\n  3650|\tdefer rows.Close()\n  3651|\n  3652|\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3653|\n  3654|\ttype AnswerItem struct {\n  3655|\t\tEntryID   string `json:\"entry_id\"`\n  3656|\t\tCreatedAt int64  `json:\"created_at\"`\n  3657|\t\tData      any    `json:\"data\"`\n  3658|\t}\n  3659|\n  3660|\tvar result []AnswerItem\n  3661|\tfor rows.Next() {\n  3662|\t\tvar e lib.Entry\n  3663|\t\terr := rows.Scan(\n  3664|\t\t\t\u0026e.EntryID, \u0026e.ProjectID, \u0026e.ParentID, \u0026e.Type, \u0026e.Depth, \u0026e.SortOrder,\n  3665|\t\t\t\u0026e.SearchKey, \u0026e.SearchKey2, \u0026e.Summary, \u0026e.Data, \u0026e.Stage,\n  3666|\t\t\t\u0026e.AssigneeID, \u0026e.ReturnToID, \u0026e.OriginID,\n  3667|\t\t\t\u0026e.Version, \u0026e.DeletedAt, \u0026e.DeletedBy, \u0026e.KeyVersion,\n  3668|\t\t\t\u0026e.CreatedAt, \u0026e.UpdatedAt, \u0026e.CreatedBy,\n  3669|\t\t)\n  3670|\t\tif err != nil {\n  3671|\t\t\tcontinue\n  3672|\t\t}\n  3673|\n  3674|\t\titem := AnswerItem{\n  3675|\t\t\tEntryID:   e.EntryID,\n  3676|\t\t\tCreatedAt: e.CreatedAt,\n  3677|\t\t}\n  3678|\n  3679|\t\tif len(e.Data) \u003e 0 {\n  3680|\t\t\tdataText, err := lib.Unpack(projectKey, e.Data)\n  3681|\t\t\tif err == nil {\n  3682|\t\t\t\tvar parsed any\n  3683|\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026parsed) == nil {\n  3684|\t\t\t\t\titem.Data = parsed\n  3685|\t\t\t\t\t// Filter by search query\n  3686|\t\t\t\t\tif q != \"\" \u0026\u0026 !strings.Contains(strings.ToLower(dataText), strings.ToLower(q)) {\n  3687|\t\t\t\t\t\tcontinue\n  3688|\t\t\t\t\t}\n  3689|\t\t\t\t}\n  3690|\t\t\t}\n  3691|\t\t}\n  3692|\n  3693|\t\tresult = append(result, item)\n  3694|\t}\n  3695|\n  3696|\tif result == nil {\n  3697|\t\tresult = []AnswerItem{}\n  3698|\t}\n  3699|\tJSONResponse(w, http.StatusOK, result)\n  3700|}\n  3701|\n  3702|// containsAny checks if s contains any of the given substrings\n  3703|func containsAny(s string, subs ...string) bool {\n  3704|\tfor _, sub := range subs {\n  3705|\t\tif strings.Contains(s, sub) {\n  3706|\t\t\treturn true\n  3707|\t\t}\n  3708|\t}\n  3709|\treturn false\n  3710|}\n  3711|\n  3712|// UpdateOrg handles PUT /api/orgs/{orgId} — update org name, domains, role, website\n  3713|\n  3714|// SetTestRole handles PUT /api/admin/test-role — super admin sets their test role for the current session.\n  3715|func (h *Handlers) SetTestRole(w http.ResponseWriter, r *http.Request) {\n  3716|\tactorID := UserIDFromContext(r.Context())\n  3717|\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\n  3718|\tif !isSuperAdmin {\n  3719|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Super admin only\")\n  3720|\t\treturn\n  3721|\t}\n  3722|\tsessionID := SessionIDFromContext(r.Context())\n  3723|\tif sessionID == \"\" {\n  3724|\t\tErrorResponse(w, http.StatusBadRequest, \"no_session\", \"No session found\")\n  3725|\t\treturn\n  3726|\t}\n  3727|\tvar req struct {\n  3728|\t\tRole string `json:\"role\"`\n  3729|\t}\n  3730|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  3731|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  3732|\t\treturn\n  3733|\t}\n  3734|\tvalidRoles := map[string]bool{\"\": true, \"ib\": true, \"buyer\": true, \"seller\": true, \"advisor\": true}\n  3735|\tif !validRoles[req.Role] {\n  3736|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_role\", \"Role must be one of: ib, buyer, seller, advisor (or empty to reset)\")\n  3737|\t\treturn\n  3738|\t}\n  3739|\tif err := lib.SetSessionTestRole(h.DB, sessionID, req.Role); err != nil {\n  3740|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to set test role\")\n  3741|\t\treturn\n  3742|\t}\n  3743|\tJSONResponse(w, http.StatusOK, map[string]any{\"ok\": true, \"test_role\": req.Role})\n  3744|}\n  3745|\n  3746|// AddOrgToDeal handles POST /api/projects/{projectID}/orgs/add — creates org (if new) + deal_org + members in one shot.\n  3747|func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {\n  3748|\tactorID := UserIDFromContext(r.Context())\n  3749|\tprojectID := chi.URLParam(r, \"projectID\")\n  3750|\n  3751|\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \"\"); err != nil {\n  3752|\t\tErrorResponse(w, http.StatusForbidden, \"access_denied\", \"Access denied\")\n  3753|\t\treturn\n  3754|\t}\n  3755|\n  3756|\tvar req struct {\n  3757|\t\t// Org info\n  3758|\t\tName        string   `json:\"name\"`\n  3759|\t\tDomains     []string `json:\"domains\"`\n  3760|\t\tRole        string   `json:\"role\"` // seller | buyer | ib | advisor\n  3761|\t\tLogo        string   `json:\"logo\"`\n  3762|\t\tWebsite     string   `json:\"website\"`\n  3763|\t\tDescription string   `json:\"description\"`\n  3764|\t\tIndustry    string   `json:\"industry\"`\n  3765|\t\tPhone       string   `json:\"phone\"`\n  3766|\t\tFax         string   `json:\"fax\"`\n  3767|\t\tAddress     string   `json:\"address\"`\n  3768|\t\tCity        string   `json:\"city\"`\n  3769|\t\tState       string   `json:\"state\"`\n  3770|\t\tCountry     string   `json:\"country\"`\n  3771|\t\tFounded     string   `json:\"founded\"`\n  3772|\t\tLinkedIn    string   `json:\"linkedin\"`\n  3773|\t\t// Selected members\n  3774|\t\tMembers []lib.DealOrgMember `json:\"members\"`\n  3775|\t\t// Deal org settings\n  3776|\t\tDomainLock bool `json:\"domain_lock\"`\n  3777|\t}\n  3778|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  3779|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  3780|\t\treturn\n  3781|\t}\n  3782|\n  3783|\tif req.Name == \"\" {\n  3784|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Organization name required\")\n  3785|\t\treturn\n  3786|\t}\n  3787|\t// Derive domain from website if not provided\n  3788|\tif len(req.Domains) == 0 \u0026\u0026 req.Website != \"\" {\n  3789|\t\tdomain := req.Website\n  3790|\t\tdomain = strings.TrimPrefix(domain, \"https://\")\n  3791|\t\tdomain = strings.TrimPrefix(domain, \"http://\")\n  3792|\t\tdomain = strings.TrimPrefix(domain, \"www.\")\n  3793|\t\tif idx := strings.Index(domain, \"/\"); idx != -1 {\n  3794|\t\t\tdomain = domain[:idx]\n  3795|\t\t}\n  3796|\t\tif domain != \"\" {\n  3797|\t\t\treq.Domains = []string{strings.ToLower(strings.TrimSpace(domain))}\n  3798|\t\t}\n  3799|\t}\n  3800|\t// Name is required; domain is best-effort\n  3801|\tif len(req.Domains) == 0 {\n  3802|\t\treq.Domains = []string{\"unknown.invalid\"}\n  3803|\t}\n  3804|\tvalidRoles := map[string]bool{\"seller\": true, \"buyer\": true, \"ib\": true, \"advisor\": true}\n  3805|\tif req.Role == \"\" || !validRoles[req.Role] {\n  3806|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_role\", \"Role must be one of: seller, buyer, ib, advisor\")\n  3807|\t\treturn\n  3808|\t}\n  3809|\n  3810|\tfor i := range req.Domains {\n  3811|\t\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\n  3812|\t}\n  3813|\n  3814|\tnow := time.Now().UnixMilli()\n  3815|\n  3816|\t// Step 1: Check if an organization with a matching domain already exists\n  3817|\torgID := \"\"\n  3818|\texistingOrgs, _ := h.queryOrgsByType(\"\")\n  3819|\tfor _, org := range existingOrgs {\n  3820|\t\torgMap := h.orgToMap(\u0026org)\n  3821|\t\tif domains, ok := orgMap[\"domains\"].([]string); ok {\n  3822|\t\t\tfor _, d := range domains {\n  3823|\t\t\t\tfor _, rd := range req.Domains {\n  3824|\t\t\t\t\tif strings.EqualFold(d, rd) {\n  3825|\t\t\t\t\t\torgID = org.EntryID\n  3826|\t\t\t\t\t\tbreak\n  3827|\t\t\t\t\t}\n  3828|\t\t\t\t}\n  3829|\t\t\t\tif orgID != \"\" {\n  3830|\t\t\t\t\tbreak\n  3831|\t\t\t\t}\n  3832|\t\t\t}\n  3833|\t\t}\n  3834|\t\tif orgID != \"\" {\n  3835|\t\t\tlog.Printf(\"reusing existing org %s for domain %v\", orgID, req.Domains)\n  3836|\t\t\tbreak\n  3837|\t\t}\n  3838|\t}\n  3839|\n  3840|\t// Create new org if no match found\n  3841|\tif orgID == \"\" {\n  3842|\t\torgID = uuid.New().String()\n  3843|\t\torgData := lib.OrgData{\n  3844|\t\t\tName:        req.Name,\n  3845|\t\t\tDomains:     req.Domains,\n  3846|\t\t\tRole:        req.Role,\n  3847|\t\t\tLogo:        req.Logo,\n  3848|\t\t\tWebsite:     req.Website,\n  3849|\t\t\tDescription: req.Description,\n  3850|\t\t\tIndustry:    req.Industry,\n  3851|\t\t\tPhone:       req.Phone,\n  3852|\t\t\tFax:         req.Fax,\n  3853|\t\t\tAddress:     req.Address,\n  3854|\t\t\tCity:        req.City,\n  3855|\t\t\tState:       req.State,\n  3856|\t\t\tCountry:     req.Country,\n  3857|\t\t\tFounded:     req.Founded,\n  3858|\t\t\tLinkedIn:    req.LinkedIn,\n  3859|\t\t}\n  3860|\t\torgDataJSON, _ := json.Marshal(orgData)\n  3861|\n  3862|\t\torgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)\n  3863|\t\tif err != nil {\n  3864|\t\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n  3865|\t\t\treturn\n  3866|\t\t}\n  3867|\t\torgSummary, _ := lib.Pack(orgKey, req.Name)\n  3868|\t\torgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))\n  3869|\n  3870|\t\t_, dbErr := h.DB.Conn.Exec(\n  3871|\t\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  3872|\t\t\t  search_key, search_key2, summary, data, stage,\n  3873|\t\t\t  assignee_id, return_to_id, origin_id,\n  3874|\t\t\t  version, deleted_at, deleted_by, key_version,\n  3875|\t\t\t  created_at, updated_at, created_by)\n  3876|\t\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  3877|\t\t\torgID, orgID, \"\", lib.TypeOrganization, 0, 0,\n  3878|\t\t\tnil, nil, orgSummary, orgDataPacked, lib.StageDataroom,\n  3879|\t\t\t\"\", \"\", \"\",\n  3880|\t\t\t1, nil, nil, 1,\n  3881|\t\t\tnow, now, actorID,\n  3882|\t\t)\n  3883|\t\tif dbErr != nil {\n  3884|\t\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to create organization\")\n  3885|\t\t\treturn\n  3886|\t\t}\n  3887|\t}\n  3888|\n  3889|\t// Step 2: Create deal_org entry linking org to project\n  3890|\tdealOrgID := uuid.New().String()\n  3891|\tdealOrgData := lib.DealOrgData{\n  3892|\t\tOrgID:      orgID,\n  3893|\t\tRole:       req.Role,\n  3894|\t\tDomainLock: req.DomainLock,\n  3895|\t\tMembers:    req.Members,\n  3896|\t}\n  3897|\tdealOrgJSON, _ := json.Marshal(dealOrgData)\n  3898|\n  3899|\tprojKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\n  3900|\tif err != nil {\n  3901|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Key derivation failed\")\n  3902|\t\treturn\n  3903|\t}\n  3904|\tdealSummary, _ := lib.Pack(projKey, req.Name)\n  3905|\tdealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON))\n  3906|\n  3907|\t_, dbErr2 := h.DB.Conn.Exec(\n  3908|\t\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\n  3909|\t\t  search_key, search_key2, summary, data, stage,\n  3910|\t\t  assignee_id, return_to_id, origin_id,\n  3911|\t\t  version, deleted_at, deleted_by, key_version,\n  3912|\t\t  created_at, updated_at, created_by)\n  3913|\t\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\n  3914|\t\tdealOrgID, projectID, projectID, lib.TypeDealOrg, 1, 0,\n  3915|\t\tnil, nil, dealSummary, dealDataPacked, lib.StagePreDataroom,\n  3916|\t\t\"\", \"\", \"\",\n  3917|\t\t1, nil, nil, 1,\n  3918|\t\tnow, now, actorID,\n  3919|\t)\n  3920|\tif dbErr2 != nil {\n  3921|\t\tErrorResponse(w, http.StatusInternalServerError, \"internal\", \"Failed to add organization to deal\")\n  3922|\t\treturn\n  3923|\t}\n  3924|\n  3925|\tJSONResponse(w, http.StatusCreated, map[string]any{\n  3926|\t\t\"org_id\":      orgID,\n  3927|\t\t\"deal_org_id\": dealOrgID,\n  3928|\t\t\"name\":        req.Name,\n  3929|\t\t\"role\":        req.Role,\n  3930|\t\t\"members\":     len(req.Members),\n  3931|\t})\n  3932|}\n  3933|\n  3934|// ScrapeOrg handles POST /api/scrape/org — takes an email, scrapes the domain for org + people data.\n  3935|func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {\n  3936|\tvar req struct {\n  3937|\t\tEmail string `json:\"email\"`\n  3938|\t}\n  3939|\tif err := json.NewDecoder(r.Body).Decode(\u0026req); err != nil {\n  3940|\t\tErrorResponse(w, http.StatusBadRequest, \"invalid_json\", \"Invalid request body\")\n  3941|\t\treturn\n  3942|\t}\n  3943|\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\n  3944|\tif req.Email == \"\" || !strings.Contains(req.Email, \"@\") {\n  3945|\t\tErrorResponse(w, http.StatusBadRequest, \"missing_fields\", \"Valid email required\")\n  3946|\t\treturn\n  3947|\t}\n  3948|\n  3949|\t// Extract domain from email\n  3950|\tdomain := strings.SplitN(req.Email, \"@\", 2)[1]\n  3951|\n  3952|\t// Check if we already have an org with this domain\n  3953|\texistingOrgs, _ := h.queryOrgsByType(\"\")\n  3954|\tfor _, org := range existingOrgs {\n  3955|\t\torgMap := h.orgToMap(\u0026org)\n  3956|\t\tif domains, ok := orgMap[\"domains\"].([]string); ok {\n  3957|\t\t\tfor _, d := range domains {\n  3958|\t\t\t\tif strings.EqualFold(d, domain) {\n  3959|\t\t\t\t\t// Return existing org data in scrape format\n  3960|\t\t\t\t\texisting := \u0026lib.ScrapedOrg{\n  3961|\t\t\t\t\t\tName:        fmt.Sprintf(\"%v\", orgMap[\"name\"]),\n  3962|\t\t\t\t\t\tDomain:      domain,\n  3963|\t\t\t\t\t\tLogo:        fmt.Sprintf(\"%v\", orgMap[\"logo\"]),\n  3964|\t\t\t\t\t\tDescription: fmt.Sprintf(\"%v\", orgMap[\"description\"]),\n  3965|\t\t\t\t\t\tIndustry:    fmt.Sprintf(\"%v\", orgMap[\"industry\"]),\n  3966|\t\t\t\t\t\tWebsite:     fmt.Sprintf(\"%v\", orgMap[\"website\"]),\n  3967|\t\t\t\t\t}\n  3968|\t\t\t\t\t// Get members from deal_orgs that reference this org\n  3969|\t\t\t\t\trows, err := h.DB.Conn.Query(\n  3970|\t\t\t\t\t\t`SELECT data, project_id FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`,\n  3971|\t\t\t\t\t)\n  3972|\t\t\t\t\tif err == nil {\n  3973|\t\t\t\t\t\tdefer rows.Close()\n  3974|\t\t\t\t\t\tfor rows.Next() {\n  3975|\t\t\t\t\t\t\tvar data []byte\n  3976|\t\t\t\t\t\t\tvar pid string\n  3977|\t\t\t\t\t\t\tif rows.Scan(\u0026data, \u0026pid) != nil || len(data) == 0 {\n  3978|\t\t\t\t\t\t\t\tcontinue\n  3979|\t\t\t\t\t\t\t}\n  3980|\t\t\t\t\t\t\tprojKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, pid)\n  3981|\t\t\t\t\t\t\tdataText, err := lib.Unpack(projKey, data)\n  3982|\t\t\t\t\t\t\tif err != nil {\n  3983|\t\t\t\t\t\t\t\tcontinue\n  3984|\t\t\t\t\t\t\t}\n  3985|\t\t\t\t\t\t\tvar dod lib.DealOrgData\n  3986|\t\t\t\t\t\t\tif json.Unmarshal([]byte(dataText), \u0026dod) == nil \u0026\u0026 dod.OrgID == org.EntryID {\n  3987|\t\t\t\t\t\t\t\tfor _, m := range dod.Members {\n  3988|\t\t\t\t\t\t\t\t\texisting.People = append(existing.People, lib.ScrapedPerson{\n  3989|\t\t\t\t\t\t\t\t\t\tName:     m.Name,\n  3990|\t\t\t\t\t\t\t\t\t\tEmail:    m.Email,\n  3991|\t\t\t\t\t\t\t\t\t\tTitle:    m.Title,\n  3992|\t\t\t\t\t\t\t\t\t\tPhone:    m.Phone,\n  3993|\t\t\t\t\t\t\t\t\t\tPhoto:    m.Photo,\n  3994|\t\t\t\t\t\t\t\t\t\tBio:      m.Bio,\n  3995|\t\t\t\t\t\t\t\t\t\tLinkedIn: m.LinkedIn,\n  3996|\t\t\t\t\t\t\t\t\t})\n  3997|\t\t\t\t\t\t\t\t}\n  3998|\t\t\t\t\t\t\t\tbreak\n  3999|\t\t\t\t\t\t\t}\n  4000|\t\t\t\t\t\t}\n\n(File has more lines. Use 'offset' parameter to read beyond line 4000)\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/api/handlers.go\",\"content\":\"\\t\\t\\treturn\\n\\t\\t}\\n\\t}\\n\\n\\t// Load the organization entry and update its details\\n\\torgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)\\n\\tif err != nil || orgEntry == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\tvar orgData lib.OrgData\\n\\tjson.Unmarshal([]byte(orgEntry.DataText), \\u0026orgData)\\n\\n\\torgData.Name = strings.TrimSpace(body.Name)\\n\\torgData.Description = body.Description\\n\\tif body.Role != \\\"\\\" { orgData.Role = body.Role }\\n\\torgData.Industry = body.Industry\\n\\torgData.Website = body.Website\\n\\torgData.Phone = body.Phone\\n\\torgData.Address = body.Address\\n\\torgData.City = body.City\\n\\torgData.State = body.State\\n\\torgData.Founded = body.Founded\\n\\torgData.LinkedIn = body.LinkedIn\\n\\tif body.Logo != \\\"\\\" { orgData.Logo = body.Logo }\\n\\n\\torgJSON, _ := json.Marshal(orgData)\\n\\torgEntry.DataText = string(orgJSON)\\n\\torgEntry.SummaryText = orgData.Name\\n\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, orgEntry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update organization\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"ok\\\": true})\\n}\\n\\nfunc (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\t// Check read access to project\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get all deal_org entries for this project\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL\\n\\t\\t ORDER BY created_at DESC`, projectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list deal organizations\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\n\\tvar result []map[string]any\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\tdealOrgMap := map[string]any{\\n\\t\\t\\t\\\"deal_org_id\\\": e.EntryID,\\n\\t\\t\\t\\\"created_at\\\":  e.CreatedAt,\\n\\t\\t\\t\\\"version\\\":     e.Version,\\n\\t\\t}\\n\\n\\t\\t// Decrypt deal_org data\\n\\t\\tif len(e.Data) \\u003e 0 {\\n\\t\\t\\tdataText, err := lib.Unpack(projectKey, e.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar dealOrgData lib.DealOrgData\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026dealOrgData) == nil {\\n\\t\\t\\t\\t\\tdealOrgMap[\\\"org_id\\\"] = dealOrgData.OrgID\\n\\t\\t\\t\\t\\tdealOrgMap[\\\"role\\\"] = dealOrgData.Role\\n\\t\\t\\t\\t\\tdealOrgMap[\\\"domain_lock\\\"] = dealOrgData.DomainLock\\n\\t\\t\\t\\t\\tdealOrgMap[\\\"permissions\\\"] = dealOrgData.Permissions\\n\\t\\t\\t\\t\\tdealOrgMap[\\\"members\\\"] = dealOrgData.Members\\n\\n\\t\\t\\t\\t\\t// Fetch org details\\n\\t\\t\\t\\t\\torg, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)\\n\\t\\t\\t\\t\\tif err == nil \\u0026\\u0026 org != nil {\\n\\t\\t\\t\\t\\t\\torgDetails := h.orgToMap(org)\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_name\\\"] = orgDetails[\\\"name\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_domains\\\"] = orgDetails[\\\"domains\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_logo\\\"] = orgDetails[\\\"logo\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_description\\\"] = orgDetails[\\\"description\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_industry\\\"] = orgDetails[\\\"industry\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_website\\\"] = orgDetails[\\\"website\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_phone\\\"] = orgDetails[\\\"phone\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_address\\\"] = orgDetails[\\\"address\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_city\\\"] = orgDetails[\\\"city\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_state\\\"] = orgDetails[\\\"state\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_founded\\\"] = orgDetails[\\\"founded\\\"]\\n\\t\\t\\t\\t\\t\\tdealOrgMap[\\\"org_linkedin\\\"] = orgDetails[\\\"linkedin\\\"]\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\n\\t\\tresult = append(result, dealOrgMap)\\n\\t}\\n\\n\\tif result == nil {\\n\\t\\tresult = []map[string]any{}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// CreateDealOrg handles POST /api/projects/{projectID}/orgs — add org to deal\\nfunc (h *Handlers) CreateDealOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\t// Check write access to project\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tOrgID      string `json:\\\"org_id\\\"`\\n\\t\\tRole       string `json:\\\"role\\\"`\\n\\t\\tDomainLock bool   `json:\\\"domain_lock\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif req.OrgID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"org_id required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Verify org exists\\n\\torg, err := lib.EntryByID(h.DB, h.Cfg, req.OrgID)\\n\\tif err != nil || org == nil || org.Type != lib.TypeOrganization {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Organization not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get project entry to use as parent\\n\\tproject, err := lib.EntryByID(h.DB, h.Cfg, projectID)\\n\\tif err != nil || project == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Project not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\tdealOrgID := uuid.New().String()\\n\\n\\tdealOrgData := lib.DealOrgData{\\n\\t\\tOrgID:      req.OrgID,\\n\\t\\tRole:       req.Role,\\n\\t\\tDomainLock: req.DomainLock,\\n\\t}\\n\\tdataJSON, _ := json.Marshal(dealOrgData)\\n\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tEntryID:     dealOrgID,\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    projectID, // Parent is the project\\n\\t\\tType:        lib.TypeDealOrg,\\n\\t\\tDepth:       1,\\n\\t\\tSummaryText: req.Role,\\n\\t\\tDataText:    string(dataJSON),\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t\\tCreatedBy:   actorID,\\n\\t\\tCreatedAt:   now,\\n\\t\\tUpdatedAt:   now,\\n\\t\\tVersion:     1,\\n\\t\\tKeyVersion:  1,\\n\\t}\\n\\n\\t// Pack encrypted fields\\n\\tkey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tsummary, err := lib.Pack(key, entry.SummaryText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdata, err := lib.Pack(key, entry.DataText)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Encryption failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tentry.Summary = summary\\n\\tentry.Data = data\\n\\n\\t_, dbErr := h.DB.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\tentry.EntryID, entry.ProjectID, entry.ParentID, entry.Type, entry.Depth, 0,\\n\\t\\tentry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,\\n\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\tentry.Version, nil, nil, entry.KeyVersion,\\n\\t\\tentry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,\\n\\t)\\n\\tif dbErr != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to add organization to deal\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get org name for response\\n\\torgName := \\\"\\\"\\n\\torgDetails := h.orgToMap(org)\\n\\tif name, ok := orgDetails[\\\"name\\\"].(string); ok {\\n\\t\\torgName = name\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]any{\\n\\t\\t\\\"deal_org_id\\\": dealOrgID,\\n\\t\\t\\\"org_id\\\":      req.OrgID,\\n\\t\\t\\\"org_name\\\":    orgName,\\n\\t\\t\\\"role\\\":        req.Role,\\n\\t\\t\\\"domain_lock\\\": req.DomainLock,\\n\\t})\\n}\\n\\n// DeleteDealOrg handles DELETE /api/projects/{projectID}/orgs/{dealOrgID} — remove org from deal\\nfunc (h *Handlers) DeleteDealOrg(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tdealOrgID := chi.URLParam(r, \\\"dealOrgID\\\")\\n\\n\\t// Check delete access to project\\n\\tif err := lib.CheckAccessDelete(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Verify deal_org exists and belongs to this project\\n\\tentry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)\\n\\tif err != nil || entry == nil || entry.Type != lib.TypeDealOrg || entry.ProjectID != projectID {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Deal organization link not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif err := lib.EntryDelete(h.DB, actorID, projectID, dealOrgID); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to remove organization from deal\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"deleted\\\"})\\n}\\n\\n// ServeAppOrgs serves the organizations page\\nfunc (h *Handlers) ServeAppOrgs(w http.ResponseWriter, r *http.Request) {\\n\\th.serveTemplatePage(w, \\\"app\\\", \\\"app/orgs.html\\\", PageData{Title: \\\"Organizations — Dealspace\\\", ActiveNav: \\\"orgs\\\"})\\n}\\n\\n// serveWebsitePage renders a website page inside the website base layout.\\nfunc (h *Handlers) serveWebsitePage(w http.ResponseWriter, page string) {\\n\\tlayoutCandidates := []string{\\n\\t\\t\\\"website/layouts/base.html\\\",\\n\\t\\t\\\"/opt/dealspace/website/layouts/base.html\\\",\\n\\t}\\n\\tpageCandidates := []string{\\n\\t\\tpage,\\n\\t\\tfilepath.Join(\\\"website/pages\\\", page),\\n\\t\\tfilepath.Join(\\\"/opt/dealspace/website/pages\\\", page),\\n\\t}\\n\\n\\tvar layoutPath, pagePath string\\n\\tfor _, p := range layoutCandidates {\\n\\t\\tif _, err := os.Stat(p); err == nil {\\n\\t\\t\\tlayoutPath = p\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\tfor _, p := range pageCandidates {\\n\\t\\tif _, err := os.Stat(p); err == nil {\\n\\t\\t\\tpagePath = p\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\n\\tif layoutPath == \\\"\\\" || pagePath == \\\"\\\" {\\n\\t\\thttp.Error(w, \\\"Template not found\\\", http.StatusInternalServerError)\\n\\t\\treturn\\n\\t}\\n\\n\\ttmpl, err := template.ParseFiles(layoutPath, pagePath)\\n\\tif err != nil {\\n\\t\\thttp.Error(w, \\\"Template parse error: \\\"+err.Error(), http.StatusInternalServerError)\\n\\t\\treturn\\n\\t}\\n\\n\\tw.Header().Set(\\\"Content-Type\\\", \\\"text/html; charset=utf-8\\\")\\n\\tif err := tmpl.ExecuteTemplate(w, \\\"layout\\\", nil); err != nil {\\n\\t\\tlog.Printf(\\\"website template error: %v\\\", err)\\n\\t}\\n}\\n\\n// ServeWebsiteIndex serves the homepage\\nfunc (h *Handlers) ServeWebsiteIndex(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"index.html\\\")\\n}\\n\\n// ServeWebsiteFeatures serves the features page\\nfunc (h *Handlers) ServeWebsiteFeatures(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"features.html\\\")\\n}\\n\\n// ServeWebsitePricing serves the pricing page\\nfunc (h *Handlers) ServeWebsitePricing(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"pricing.html\\\")\\n}\\n\\n// ServeWebsiteSecurity serves the security page\\nfunc (h *Handlers) ServeWebsiteSecurity(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"security.html\\\")\\n}\\n\\n// ServeWebsitePrivacy serves the privacy page\\nfunc (h *Handlers) ServeWebsitePrivacy(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"privacy.html\\\")\\n}\\n\\n// ServeWebsiteTerms serves the terms page\\nfunc (h *Handlers) ServeWebsiteTerms(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"terms.html\\\")\\n}\\n\\n// ServeWebsiteDPA serves the DPA page\\nfunc (h *Handlers) ServeWebsiteDPA(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"dpa.html\\\")\\n}\\n\\n// ServeWebsiteSOC2 serves the SOC 2 page\\nfunc (h *Handlers) ServeWebsiteSOC2(w http.ResponseWriter, r *http.Request) {\\n\\th.serveWebsitePage(w, \\\"soc2.html\\\")\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Request Import/List API endpoints\\n// ---------------------------------------------------------------------------\\n\\n// ListRequests handles GET /api/projects/{projectID}/requests\\n// Returns all request entries for a project, sorted by section + item_number\\n// ListTemplates returns all saved request templates.\\nfunc (h *Handlers) ListTemplates(w http.ResponseWriter, r *http.Request) {\\n\\ttemplates, err := lib.ListTemplates(h.DB, h.Cfg)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list templates\\\")\\n\\t\\treturn\\n\\t}\\n\\tresult := []map[string]any{}\\n\\tfor _, t := range templates {\\n\\t\\tvar data lib.RequestTemplateData\\n\\t\\tjson.Unmarshal([]byte(t.DataText), \\u0026data)\\n\\t\\tresult = append(result, map[string]any{\\n\\t\\t\\t\\\"entry_id\\\":    t.EntryID,\\n\\t\\t\\t\\\"name\\\":        data.Name,\\n\\t\\t\\t\\\"description\\\": data.Description,\\n\\t\\t\\t\\\"item_count\\\":  len(data.Items),\\n\\t\\t\\t\\\"created_at\\\":  t.CreatedAt,\\n\\t\\t})\\n\\t}\\n\\t// Also include built-in disk templates as a fallback\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// SaveTemplate saves a request list as a reusable template.\\nfunc (h *Handlers) SaveTemplate(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tvar body struct {\\n\\t\\tName        string                      `json:\\\"name\\\"`\\n\\t\\tDescription string                      `json:\\\"description\\\"`\\n\\t\\tItems       []lib.RequestTemplateItem   `json:\\\"items\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil || strings.TrimSpace(body.Name) == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"name and items required\\\")\\n\\t\\treturn\\n\\t}\\n\\tdata := lib.RequestTemplateData{\\n\\t\\tName:        strings.TrimSpace(body.Name),\\n\\t\\tDescription: body.Description,\\n\\t\\tItems:       body.Items,\\n\\t}\\n\\tdataJSON, _ := json.Marshal(data)\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   lib.TemplateProjectID,\\n\\t\\tParentID:    lib.TemplateProjectID,\\n\\t\\tType:        lib.TypeRequestTemplate,\\n\\t\\tDepth:       1,\\n\\t\\tSortOrder:   0,\\n\\t\\tSummaryText: data.Name,\\n\\t\\tDataText:    string(dataJSON),\\n\\t\\tStage:       lib.StageDataroom,\\n\\t}\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to save template\\\")\\n\\t\\treturn\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"entry_id\\\": entry.EntryID, \\\"name\\\": data.Name})\\n}\\n\\n// ImportTemplate imports a template (built-in or saved) into a project as a new request list.\\nfunc (h *Handlers) ImportTemplate(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar body struct {\\n\\t\\tTemplateID string `json:\\\"template_id\\\"` // entry_id of saved template, or built-in name\\n\\t\\tName       string `json:\\\"name\\\"`        // override list name (optional)\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil || body.TemplateID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"template_id required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar tmplData lib.RequestTemplateData\\n\\n\\t// Try loading from DB first\\n\\ttmplEntry, err := lib.TemplateByID(h.DB, h.Cfg, body.TemplateID)\\n\\tif err == nil \\u0026\\u0026 tmplEntry != nil {\\n\\t\\tjson.Unmarshal([]byte(tmplEntry.DataText), \\u0026tmplData)\\n\\t} else {\\n\\t\\t// Fall back to disk templates\\n\\t\\tdirs := []string{\\\"templates\\\", \\\"/opt/dealspace/templates\\\"}\\n\\t\\tloaded := false\\n\\t\\tfor _, dir := range dirs {\\n\\t\\t\\tfp := dir + \\\"/\\\" + body.TemplateID + \\\".json\\\"\\n\\t\\t\\tif data, err := os.ReadFile(fp); err == nil {\\n\\t\\t\\t\\tjson.Unmarshal(data, \\u0026tmplData)\\n\\t\\t\\t\\tloaded = true\\n\\t\\t\\t\\tbreak\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tif !loaded {\\n\\t\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Template not found\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t}\\n\\n\\tlistName := strings.TrimSpace(body.Name)\\n\\tif listName == \\\"\\\" {\\n\\t\\tlistName = tmplData.Name\\n\\t}\\n\\n\\t// Create the request list\\n\\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID)\\n\\tlistCount := 0\\n\\tfor _, s := range siblings {\\n\\t\\tif s.Type == lib.TypeRequestList {\\n\\t\\t\\tlistCount++\\n\\t\\t}\\n\\t}\\n\\trlData := lib.RequestListData{Name: listName}\\n\\trlDataJSON, _ := json.Marshal(rlData)\\n\\tlistEntry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    projectID,\\n\\t\\tType:        lib.TypeRequestList,\\n\\t\\tDepth:       1,\\n\\t\\tSortOrder:   (listCount + 1) * 1000,\\n\\t\\tSummaryText: listName,\\n\\t\\tDataText:    string(rlDataJSON),\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, listEntry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create request list\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Group items by section, create section entries then requests\\n\\ttype sectionGroup struct {\\n\\t\\tname  string\\n\\t\\titems []lib.RequestTemplateItem\\n\\t}\\n\\tvar sections []sectionGroup\\n\\tsectionIndex := map[string]int{}\\n\\tfor _, item := range tmplData.Items {\\n\\t\\tsec := item.Section\\n\\t\\tif sec == \\\"\\\" {\\n\\t\\t\\tsec = \\\"General\\\"\\n\\t\\t}\\n\\t\\tif idx, ok := sectionIndex[sec]; ok {\\n\\t\\t\\tsections[idx].items = append(sections[idx].items, item)\\n\\t\\t} else {\\n\\t\\t\\tsectionIndex[sec] = len(sections)\\n\\t\\t\\tsections = append(sections, sectionGroup{name: sec, items: []lib.RequestTemplateItem{item}})\\n\\t\\t}\\n\\t}\\n\\n\\tfor si, sg := range sections {\\n\\t\\tsecData := lib.SectionData{Name: sg.name}\\n\\t\\tsecDataJSON, _ := json.Marshal(secData)\\n\\t\\tsecEntry := \\u0026lib.Entry{\\n\\t\\t\\tProjectID:   projectID,\\n\\t\\t\\tParentID:    listEntry.EntryID,\\n\\t\\t\\tType:        lib.TypeSection,\\n\\t\\t\\tDepth:       2,\\n\\t\\t\\tSortOrder:   (si + 1) * 1000,\\n\\t\\t\\tSummaryText: sg.name,\\n\\t\\t\\tDataText:    string(secDataJSON),\\n\\t\\t\\tStage:       lib.StagePreDataroom,\\n\\t\\t}\\n\\t\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, secEntry); err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\t\\tfor ri, item := range sg.items {\\n\\t\\t\\treqData := lib.RequestData{\\n\\t\\t\\t\\tTitle:      item.Title,\\n\\t\\t\\t\\tItemNumber: item.ItemNumber,\\n\\t\\t\\t\\tSection:    sg.name,\\n\\t\\t\\t\\tPriority:   item.Priority,\\n\\t\\t\\t\\tStatus:     \\\"open\\\",\\n\\t\\t\\t}\\n\\t\\t\\tif reqData.Priority == \\\"\\\" {\\n\\t\\t\\t\\treqData.Priority = \\\"medium\\\"\\n\\t\\t\\t}\\n\\t\\t\\treqDataJSON, _ := json.Marshal(reqData)\\n\\t\\t\\treqEntry := \\u0026lib.Entry{\\n\\t\\t\\t\\tProjectID:   projectID,\\n\\t\\t\\t\\tParentID:    secEntry.EntryID,\\n\\t\\t\\t\\tType:        lib.TypeRequest,\\n\\t\\t\\t\\tDepth:       3,\\n\\t\\t\\t\\tSortOrder:   (ri + 1) * 100,\\n\\t\\t\\t\\tSummaryText: item.Title,\\n\\t\\t\\t\\tDataText:    string(reqDataJSON),\\n\\t\\t\\t\\tStage:       lib.StagePreDataroom,\\n\\t\\t\\t}\\n\\t\\t\\tlib.EntryWrite(h.DB, h.Cfg, actorID, reqEntry)\\n\\t\\t}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"entry_id\\\": listEntry.EntryID,\\n\\t\\t\\\"name\\\":     listName,\\n\\t\\t\\\"sections\\\": len(sections),\\n\\t\\t\\\"items\\\":    len(tmplData.Items),\\n\\t})\\n}\\n\\nfunc (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get all request entries for this project\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries WHERE project_id = ? AND type = 'request' AND deleted_at IS NULL\\n\\t\\t ORDER BY sort_order ASC, created_at ASC`, projectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list requests\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\n\\ttype RequestItem struct {\\n\\t\\tEntryID    string          `json:\\\"entry_id\\\"`\\n\\t\\tProjectID  string          `json:\\\"project_id\\\"`\\n\\t\\tSection    string          `json:\\\"section\\\"`\\n\\t\\tItemNumber string          `json:\\\"item_number\\\"`\\n\\t\\tTitle      string          `json:\\\"title\\\"`\\n\\t\\tPriority   string          `json:\\\"priority\\\"`\\n\\t\\tStatus     string          `json:\\\"status\\\"`\\n\\t\\tCreatedAt  int64           `json:\\\"created_at\\\"`\\n\\t\\tData       lib.RequestData `json:\\\"data\\\"`\\n\\t}\\n\\n\\tvar requests []RequestItem\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\titem := RequestItem{\\n\\t\\t\\tEntryID:   e.EntryID,\\n\\t\\t\\tProjectID: e.ProjectID,\\n\\t\\t\\tCreatedAt: e.CreatedAt,\\n\\t\\t}\\n\\n\\t\\t// Decrypt data\\n\\t\\tif len(e.Data) \\u003e 0 {\\n\\t\\t\\tdataText, err := lib.Unpack(projectKey, e.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar reqData lib.RequestData\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026reqData) == nil {\\n\\t\\t\\t\\t\\titem.Section = reqData.Section\\n\\t\\t\\t\\t\\titem.ItemNumber = reqData.ItemNumber\\n\\t\\t\\t\\t\\titem.Title = reqData.Title\\n\\t\\t\\t\\t\\titem.Priority = reqData.Priority\\n\\t\\t\\t\\t\\titem.Status = reqData.Status\\n\\t\\t\\t\\t\\titem.Data = reqData\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\n\\t\\trequests = append(requests, item)\\n\\t}\\n\\n\\t// Sort by section, then item_number\\n\\t// (already sorted by created_at from query, which preserves import order)\\n\\n\\tif requests == nil {\\n\\t\\trequests = []RequestItem{}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, requests)\\n}\\n\\n// ImportRequests handles POST /api/projects/{projectID}/requests/import\\n// Accepts multipart form with CSV/XLSX file, mode (add/replace), section_filter, create_workstreams\\n// CreateSection creates a new section under a request_list.\\nfunc (h *Handlers) CreateSection(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\"); return\\n\\t}\\n\\tvar body struct {\\n\\t\\tName     string `json:\\\"name\\\"`\\n\\t\\tParentID string `json:\\\"parent_id\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil || strings.TrimSpace(body.Name) == \\\"\\\" || body.ParentID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"name and parent_id required\\\"); return\\n\\t}\\n\\tparent, err := lib.EntryByID(h.DB, h.Cfg, body.ParentID)\\n\\tif err != nil || parent == nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_parent\\\", \\\"Parent entry not found\\\"); return\\n\\t}\\n\\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, body.ParentID)\\n\\tname := strings.TrimSpace(body.Name)\\n\\tsecData := lib.SectionData{Name: name}\\n\\tsecDataJSON, _ := json.Marshal(secData)\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    body.ParentID,\\n\\t\\tType:        lib.TypeSection,\\n\\t\\tDepth:       parent.Depth + 1,\\n\\t\\tSortOrder:   (len(siblings) + 1) * 1000,\\n\\t\\tSummaryText: name,\\n\\t\\tDataText:    string(secDataJSON),\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create section\\\"); return\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"entry_id\\\": entry.EntryID, \\\"name\\\": name})\\n}\\n\\n// CreateRequest creates a new blank request under a section or request_list.\\nfunc (h *Handlers) CreateRequest(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\"); return\\n\\t}\\n\\tvar body struct {\\n\\t\\tTitle    string `json:\\\"title\\\"`\\n\\t\\tParentID string `json:\\\"parent_id\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil || strings.TrimSpace(body.Title) == \\\"\\\" || body.ParentID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"title and parent_id required\\\"); return\\n\\t}\\n\\tparent, err := lib.EntryByID(h.DB, h.Cfg, body.ParentID)\\n\\tif err != nil || parent == nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_parent\\\", \\\"Parent entry not found\\\"); return\\n\\t}\\n\\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, body.ParentID)\\n\\ttitle := strings.TrimSpace(body.Title)\\n\\treqData := lib.RequestData{Title: title, Status: \\\"open\\\", Priority: \\\"medium\\\"}\\n\\treqDataJSON, _ := json.Marshal(reqData)\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    body.ParentID,\\n\\t\\tType:        lib.TypeRequest,\\n\\t\\tDepth:       parent.Depth + 1,\\n\\t\\tSortOrder:   (len(siblings) + 1) * 100,\\n\\t\\tSummaryText: title,\\n\\t\\tDataText:    string(reqDataJSON),\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create request\\\"); return\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"entry_id\\\": entry.EntryID, \\\"title\\\": title})\\n}\\n\\n// CreateRequestList creates a new empty request list for a project.\\nfunc (h *Handlers) CreateRequestList(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar body struct {\\n\\t\\tName string `json:\\\"name\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026body); err != nil || strings.TrimSpace(body.Name) == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_body\\\", \\\"name is required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tlistName := strings.TrimSpace(body.Name)\\n\\t// Sort after existing lists: use sibling count * 1000\\n\\tsiblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID)\\n\\tlistCount := 0\\n\\tfor _, s := range siblings {\\n\\t\\tif s.Type == lib.TypeRequestList {\\n\\t\\t\\tlistCount++\\n\\t\\t}\\n\\t}\\n\\trlData := lib.RequestListData{Name: listName}\\n\\trlDataJSON, _ := json.Marshal(rlData)\\n\\tentry := \\u0026lib.Entry{\\n\\t\\tProjectID:   projectID,\\n\\t\\tParentID:    projectID,\\n\\t\\tType:        lib.TypeRequestList,\\n\\t\\tDepth:       1,\\n\\t\\tSortOrder:   (listCount + 1) * 1000,\\n\\t\\tSummaryText: listName,\\n\\t\\tDataText:    string(rlDataJSON),\\n\\t\\tStage:       lib.StagePreDataroom,\\n\\t}\\n\\tif err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create request list\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"entry_id\\\": entry.EntryID,\\n\\t\\t\\\"name\\\":     listName,\\n\\t})\\n}\\n\\nfunc (h *Handlers) ImportRequests(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Parse multipart form (max 20MB)\\n\\tif err := r.ParseMultipartForm(20 \\u003c\\u003c 20); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_form\\\", \\\"Failed to parse form data\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tfile, header, err := r.FormFile(\\\"file\\\")\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_file\\\", \\\"File is required\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer file.Close()\\n\\n\\tmode := r.FormValue(\\\"mode\\\")\\n\\tif mode == \\\"\\\" {\\n\\t\\tmode = \\\"add\\\"\\n\\t}\\n\\tsectionFilter := r.FormValue(\\\"section_filter\\\")\\n\\n\\t// Read file into memory\\n\\traw, err := io.ReadAll(file)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to read file\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Detect file type by extension or magic bytes\\n\\tfname := strings.ToLower(header.Filename)\\n\\tisXLSX := strings.HasSuffix(fname, \\\".xlsx\\\") || strings.HasSuffix(fname, \\\".xls\\\") ||\\n\\t\\t(len(raw) \\u003e= 2 \\u0026\\u0026 raw[0] == 'P' \\u0026\\u0026 raw[1] == 'K')\\n\\n\\tvar rows [][]string\\n\\n\\tif isXLSX {\\n\\t\\txf, err := excelize.OpenReader(bytes.NewReader(raw))\\n\\t\\tif err != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_xlsx\\\", \\\"Failed to parse XLSX: \\\"+err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tsheetName := xf.GetSheetName(0)\\n\\t\\txlRows, err := xf.GetRows(sheetName)\\n\\t\\tif err != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_xlsx\\\", \\\"Failed to read sheet: \\\"+err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\trows = xlRows\\n\\t} else {\\n\\t\\treader := csv.NewReader(bufio.NewReader(bytes.NewReader(raw)))\\n\\t\\treader.FieldsPerRecord = -1\\n\\t\\treader.TrimLeadingSpace = true\\n\\t\\tcsvRows, err := reader.ReadAll()\\n\\t\\tif err != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_csv\\\", \\\"Failed to parse CSV: \\\"+err.Error())\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\trows = csvRows\\n\\t}\\n\\n\\t// Log first 12 rows for debugging\\n\\tfor ri, row := range rows {\\n\\t\\tif ri \\u003e= 12 {\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t\\tlog.Printf(\\\"[import-debug] row %d: %v\\\", ri, row)\\n\\t}\\n\\n\\t// Smart header detection: scan first 12 rows for keyword matches\\n\\tidxSection := -1\\n\\tidxItem := -1\\n\\tidxDesc := -1\\n\\tidxPriority := -1\\n\\theaderRowIdx := 0\\n\\tbestScore := 0\\n\\n\\tfor ri, record := range rows {\\n\\t\\tif ri \\u003e= 12 {\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t\\tscore := 0\\n\\t\\ttmpSection, tmpItem, tmpDesc, tmpPri := -1, -1, -1, -1\\n\\t\\tfor ci, cell := range record {\\n\\t\\t\\th := strings.ToLower(strings.TrimSpace(cell))\\n\\t\\t\\tif h == \\\"\\\" {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\tif containsAny(h, \\\"section\\\", \\\"category\\\", \\\"topic\\\", \\\"area\\\", \\\"phase\\\", \\\"workstream\\\") {\\n\\t\\t\\t\\ttmpSection = ci\\n\\t\\t\\t\\tscore += 3\\n\\t\\t\\t} else if containsAny(h, \\\"description\\\", \\\"request\\\", \\\"document\\\", \\\"information requested\\\", \\\"detail\\\") {\\n\\t\\t\\t\\ttmpDesc = ci\\n\\t\\t\\t\\tscore += 3\\n\\t\\t\\t} else if containsAny(h, \\\"priority\\\", \\\"urgency\\\", \\\"importance\\\", \\\"criticality\\\") {\\n\\t\\t\\t\\ttmpPri = ci\\n\\t\\t\\t\\tscore += 2\\n\\t\\t\\t} else if h == \\\"#\\\" || h == \\\"no.\\\" || h == \\\"no\\\" || h == \\\"item #\\\" || h == \\\"item#\\\" ||\\n\\t\\t\\t\\tcontainsAny(h, \\\"item no\\\", \\\"ref no\\\", \\\"ref #\\\") {\\n\\t\\t\\t\\ttmpItem = ci\\n\\t\\t\\t\\tscore += 2\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tif score \\u003e bestScore {\\n\\t\\t\\tbestScore = score\\n\\t\\t\\theaderRowIdx = ri\\n\\t\\t\\tif tmpSection \\u003e= 0 {\\n\\t\\t\\t\\tidxSection = tmpSection\\n\\t\\t\\t}\\n\\t\\t\\tif tmpItem \\u003e= 0 {\\n\\t\\t\\t\\tidxItem = tmpItem\\n\\t\\t\\t}\\n\\t\\t\\tif tmpDesc \\u003e= 0 {\\n\\t\\t\\t\\tidxDesc = tmpDesc\\n\\t\\t\\t}\\n\\t\\t\\tif tmpPri \\u003e= 0 {\\n\\t\\t\\t\\tidxPriority = tmpPri\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\t// Fall back to positional if no header found\\n\\tif bestScore \\u003c 2 {\\n\\t\\theaderRowIdx = 0\\n\\t\\tidxSection = 0\\n\\t\\tidxItem = 1\\n\\t\\tidxDesc = 2\\n\\t}\\n\\n\\t// If desc still not found, pick column with longest average text\\n\\tif idxDesc \\u003c 0 \\u0026\\u0026 len(rows) \\u003e headerRowIdx+1 {\\n\\t\\tmaxLen := 0\\n\\t\\tfor ci := range rows[headerRowIdx] {\\n\\t\\t\\ttotal := 0\\n\\t\\t\\tcount := 0\\n\\t\\t\\tfor ri := headerRowIdx + 1; ri \\u003c len(rows) \\u0026\\u0026 ri \\u003c headerRowIdx+20; ri++ {\\n\\t\\t\\t\\tif ci \\u003c len(rows[ri]) {\\n\\t\\t\\t\\t\\ttotal += len(strings.TrimSpace(rows[ri][ci]))\\n\\t\\t\\t\\t\\tcount++\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t\\tavg := 0\\n\\t\\t\\tif count \\u003e 0 {\\n\\t\\t\\t\\tavg = total / count\\n\\t\\t\\t}\\n\\t\\t\\tif avg \\u003e maxLen \\u0026\\u0026 ci != idxSection \\u0026\\u0026 ci != idxItem {\\n\\t\\t\\t\\tmaxLen = avg\\n\\t\\t\\t\\tidxDesc = ci\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\tlog.Printf(\\\"[import-debug] header at row %d (score=%d) | section=%d item=%d desc=%d priority=%d\\\",\\n\\t\\theaderRowIdx, bestScore, idxSection, idxItem, idxDesc, idxPriority)\\n\\n\\t// Parse rows into request items\\n\\ttype reqRow struct {\\n\\t\\tsection, itemNumber, description, priority string\\n\\t}\\n\\tvar items []reqRow\\n\\tsections := make(map[string]bool)\\n\\n\\tfor ri, record := range rows {\\n\\t\\tif ri \\u003c= headerRowIdx {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\t\\tif len(record) == 0 {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\t\\t// Skip blank rows\\n\\t\\tallBlank := true\\n\\t\\tfor _, c := range record {\\n\\t\\t\\tif strings.TrimSpace(c) != \\\"\\\" {\\n\\t\\t\\t\\tallBlank = false\\n\\t\\t\\t\\tbreak\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tif allBlank {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\tget := func(idx int) string {\\n\\t\\t\\tif idx \\u003e= 0 \\u0026\\u0026 idx \\u003c len(record) {\\n\\t\\t\\t\\treturn strings.TrimSpace(record[idx])\\n\\t\\t\\t}\\n\\t\\t\\treturn \\\"\\\"\\n\\t\\t}\\n\\n\\t\\tdesc := get(idxDesc)\\n\\t\\tif desc == \\\"\\\" {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\tsection := get(idxSection)\\n\\t\\tif sectionFilter != \\\"\\\" \\u0026\\u0026 !strings.EqualFold(section, sectionFilter) {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\tpriority := \\\"medium\\\"\\n\\t\\tif idxPriority \\u003e= 0 {\\n\\t\\t\\tp := strings.ToLower(get(idxPriority))\\n\\t\\t\\tswitch {\\n\\t\\t\\tcase strings.Contains(p, \\\"high\\\") || strings.Contains(p, \\\"critical\\\") || strings.Contains(p, \\\"urgent\\\"):\\n\\t\\t\\t\\tpriority = \\\"high\\\"\\n\\t\\t\\tcase strings.Contains(p, \\\"low\\\") || strings.Contains(p, \\\"nice\\\") || strings.Contains(p, \\\"optional\\\"):\\n\\t\\t\\t\\tpriority = \\\"low\\\"\\n\\t\\t\\t}\\n\\t\\t}\\n\\n\\t\\titems = append(items, reqRow{\\n\\t\\t\\tsection:     section,\\n\\t\\t\\titemNumber:  get(idxItem),\\n\\t\\t\\tdescription: desc,\\n\\t\\t\\tpriority:    priority,\\n\\t\\t})\\n\\t\\tsections[section] = true\\n\\t}\\n\\n\\tif len(items) == 0 {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"no_items\\\", \\\"No valid items found in file\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Handle replace mode: soft-delete existing request_lists and their subtrees\\n\\tif mode == \\\"replace\\\" {\\n\\t\\t// Find all request_list entries for this project\\n\\t\\trlRows, err := h.DB.Conn.Query(\\n\\t\\t\\t`SELECT entry_id FROM entries WHERE project_id = ? AND type IN ('request_list','section','request') AND deleted_at IS NULL`,\\n\\t\\t\\tprojectID,\\n\\t\\t)\\n\\t\\tif err == nil {\\n\\t\\t\\tdefer rlRows.Close()\\n\\t\\t\\tnow := time.Now().UnixMilli()\\n\\t\\t\\tfor rlRows.Next() {\\n\\t\\t\\t\\tvar eid string\\n\\t\\t\\t\\trlRows.Scan(\\u0026eid)\\n\\t\\t\\t\\th.DB.Conn.Exec(\\n\\t\\t\\t\\t\\t`UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`,\\n\\t\\t\\t\\t\\tnow, actorID, now, eid,\\n\\t\\t\\t\\t)\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\t// Derive project key for encryption\\n\\tprojectKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\timported := 0\\n\\tskipped := 0\\n\\n\\t// Get list_name from form (defaults to filename without extension)\\n\\tlistName := r.FormValue(\\\"list_name\\\")\\n\\tif listName == \\\"\\\" {\\n\\t\\tlistName = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))\\n\\t}\\n\\n\\t// 1. Create request_list entry (depth=1, parent=project)\\n\\trequestListID := uuid.New().String()\\n\\tvisibilityOrgID := r.FormValue(\\\"visibility_org_id\\\")\\n\\trlData := lib.RequestListData{Name: listName, VisibilityOrgID: visibilityOrgID}\\n\\trlDataJSON, _ := json.Marshal(rlData)\\n\\trlSummaryPacked, _ := lib.Pack(projectKey, listName)\\n\\trlDataPacked, _ := lib.Pack(projectKey, string(rlDataJSON))\\n\\n\\tvar importMaxSort int\\n\\th.DB.Conn.QueryRow(\\n\\t\\t`SELECT COALESCE(MAX(sort_order),0) FROM entries WHERE project_id=? AND type='request_list' AND deleted_at IS NULL`, projectID,\\n\\t).Scan(\\u0026importMaxSort)\\n\\trlSortOrder := importMaxSort + 1000\\n\\n\\th.DB.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\trequestListID, projectID, projectID, lib.TypeRequestList, 1, rlSortOrder,\\n\\t\\tnil, nil, rlSummaryPacked, rlDataPacked, lib.StagePreDataroom,\\n\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\t1, nil, nil, 1,\\n\\t\\tnow, now, actorID,\\n\\t)\\n\\n\\t// 2. Group items by section and create section + request entries\\n\\t// Preserve section order from the file\\n\\tvar sectionOrder []string\\n\\tsectionSeen := map[string]bool{}\\n\\tsectionItems := map[string][]reqRow{}\\n\\tfor _, item := range items {\\n\\t\\tsec := item.section\\n\\t\\tif sec == \\\"\\\" {\\n\\t\\t\\tsec = \\\"General\\\"\\n\\t\\t}\\n\\t\\tif !sectionSeen[sec] {\\n\\t\\t\\tsectionSeen[sec] = true\\n\\t\\t\\tsectionOrder = append(sectionOrder, sec)\\n\\t\\t}\\n\\t\\tsectionItems[sec] = append(sectionItems[sec], item)\\n\\t}\\n\\n\\tfor secIdx, secName := range sectionOrder {\\n\\t\\t// Create section entry (depth=2, parent=request_list)\\n\\t\\tsectionID := uuid.New().String()\\n\\t\\tsecData := lib.SectionData{Name: secName}\\n\\t\\tsecDataJSON, _ := json.Marshal(secData)\\n\\t\\tsecSummaryPacked, _ := lib.Pack(projectKey, secName)\\n\\t\\tsecDataPacked, _ := lib.Pack(projectKey, string(secDataJSON))\\n\\n\\t\\th.DB.Conn.Exec(\\n\\t\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t\\t  created_at, updated_at, created_by)\\n\\t\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\t\\tsectionID, projectID, requestListID, lib.TypeSection, 2, (secIdx+1)*1000,\\n\\t\\t\\tnil, nil, secSummaryPacked, secDataPacked, lib.StagePreDataroom,\\n\\t\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\t\\t1, nil, nil, 1,\\n\\t\\t\\tnow, now, actorID,\\n\\t\\t)\\n\\n\\t\\t// Create request entries under this section (depth=3)\\n\\t\\tfor reqIdx, item := range sectionItems[secName] {\\n\\t\\t\\tentryID := uuid.New().String()\\n\\n\\t\\t\\tsummary := item.description\\n\\t\\t\\tif len(summary) \\u003e 120 {\\n\\t\\t\\t\\tsummary = summary[:120]\\n\\t\\t\\t}\\n\\n\\t\\t\\t// Normalize priority\\n\\t\\t\\tpriority := item.priority\\n\\t\\t\\tswitch strings.ToLower(priority) {\\n\\t\\t\\tcase \\\"critical\\\", \\\"urgent\\\":\\n\\t\\t\\t\\tpriority = \\\"critical\\\"\\n\\t\\t\\tcase \\\"high\\\":\\n\\t\\t\\t\\tpriority = \\\"high\\\"\\n\\t\\t\\tcase \\\"low\\\", \\\"optional\\\", \\\"nice\\\":\\n\\t\\t\\t\\tpriority = \\\"low\\\"\\n\\t\\t\\tdefault:\\n\\t\\t\\t\\tpriority = \\\"medium\\\"\\n\\t\\t\\t}\\n\\n\\t\\t\\treqData := lib.RequestData{\\n\\t\\t\\t\\tTitle:       item.description,\\n\\t\\t\\t\\tItemNumber:  item.itemNumber,\\n\\t\\t\\t\\tSection:     secName,\\n\\t\\t\\t\\tDescription: item.description,\\n\\t\\t\\t\\tPriority:    priority,\\n\\t\\t\\t\\tStatus:      \\\"open\\\",\\n\\t\\t\\t}\\n\\t\\t\\tdataJSON, _ := json.Marshal(reqData)\\n\\n\\t\\t\\tsummaryPacked, err := lib.Pack(projectKey, summary)\\n\\t\\t\\tif err != nil {\\n\\t\\t\\t\\tskipped++\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\tdataPacked, err := lib.Pack(projectKey, string(dataJSON))\\n\\t\\t\\tif err != nil {\\n\\t\\t\\t\\tskipped++\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\n\\t\\t\\t_, err = h.DB.Conn.Exec(\\n\\t\\t\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t\\t\\t  created_at, updated_at, created_by)\\n\\t\\t\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\t\\t\\tentryID, projectID, sectionID, lib.TypeRequest, 3, (reqIdx+1)*1000,\\n\\t\\t\\t\\tnil, nil, summaryPacked, dataPacked, lib.StagePreDataroom,\\n\\t\\t\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\t\\t\\t1, nil, nil, 1,\\n\\t\\t\\t\\tnow, now, actorID,\\n\\t\\t\\t)\\n\\t\\t\\tif err != nil {\\n\\t\\t\\t\\tlog.Printf(\\\"Failed to insert request: %v\\\", err)\\n\\t\\t\\t\\tskipped++\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\timported++\\n\\t\\t}\\n\\t}\\n\\n\\tlog.Printf(\\\"[import] list=%q, total rows: %d, header row: %d, imported: %d, skipped: %d, sections: %v\\\",\\n\\t\\tlistName, len(rows), headerRowIdx, imported, skipped, sectionOrder)\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\n\\t\\t\\\"imported\\\":        imported,\\n\\t\\t\\\"skipped\\\":         skipped,\\n\\t\\t\\\"sections\\\":        sectionOrder,\\n\\t\\t\\\"request_list_id\\\": requestListID,\\n\\t})\\n}\\n\\n// UpdateRequestListVisibility handles PATCH /api/projects/{projectID}/entries/{entryID}/visibility\\n// Sets or clears the visibility_org_id on a request_list entry. IB roles only.\\nfunc (h *Handlers) UpdateRequestListVisibility(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tentryID := chi.URLParam(r, \\\"entryID\\\")\\n\\n\\t// Only IB roles can change visibility\\n\\trole, err := lib.GetUserHighestRole(h.DB, actorID, projectID)\\n\\tif err != nil || (role != lib.RoleIBAdmin \\u0026\\u0026 role != lib.RoleIBMember) {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Only IB roles can change list visibility\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tVisibilityOrgID *string `json:\\\"visibility_org_id\\\"` // null to clear\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Read the entry and update its data\\n\\tentry, err := lib.EntryByID(h.DB, h.Cfg, entryID)\\n\\tif err != nil || entry == nil || entry.Type != lib.TypeRequestList || entry.ProjectID != projectID {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Request list not found\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar rlData lib.RequestListData\\n\\tif entry.DataText != \\\"\\\" {\\n\\t\\tjson.Unmarshal([]byte(entry.DataText), \\u0026rlData)\\n\\t}\\n\\n\\tif req.VisibilityOrgID != nil {\\n\\t\\trlData.VisibilityOrgID = *req.VisibilityOrgID\\n\\t} else {\\n\\t\\trlData.VisibilityOrgID = \\\"\\\"\\n\\t}\\n\\n\\tdataJSON, _ := json.Marshal(rlData)\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\tdataPacked, _ := lib.Pack(projectKey, string(dataJSON))\\n\\n\\tnow := time.Now().UnixMilli()\\n\\t_, err = h.DB.Conn.Exec(\\n\\t\\t`UPDATE entries SET data = ?, updated_at = ?, version = version + 1 WHERE entry_id = ?`,\\n\\t\\tdataPacked, now, entryID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to update visibility\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"updated\\\"})\\n}\\n\\n// ---------------------------------------------------------------------------\\n// Tree API endpoints\\n// ---------------------------------------------------------------------------\\n\\n// ListRequestTree handles GET /api/projects/{projectID}/requests/tree\\n// Returns all request_list, section, and request entries in DFS tree order.\\nfunc (h *Handlers) ListRequestTree(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get actor's role and org for visibility filtering\\n\\tactorRole, _ := lib.GetUserHighestRole(h.DB, actorID, projectID)\\n\\tactorUser, _ := lib.UserByID(h.DB, actorID)\\n\\tactorOrgID := \\\"\\\"\\n\\tif actorUser != nil {\\n\\t\\tactorOrgID = actorUser.OrgID\\n\\t}\\n\\tisIBRole := actorRole == lib.RoleIBAdmin || actorRole == lib.RoleIBMember\\n\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries\\n\\t\\t WHERE project_id = ? AND type IN ('request_list','section','request') AND deleted_at IS NULL\\n\\t\\t ORDER BY sort_order ASC, created_at ASC`, projectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list request tree\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\n\\t// Collect all entries\\n\\ttype treeEntry struct {\\n\\t\\tEntryID   string `json:\\\"entry_id\\\"`\\n\\t\\tParentID  string `json:\\\"parent_id\\\"`\\n\\t\\tType      string `json:\\\"type\\\"`\\n\\t\\tDepth     int    `json:\\\"depth\\\"`\\n\\t\\tSortOrder int    `json:\\\"sort_order\\\"`\\n\\t\\tVersion   int    `json:\\\"version\\\"`\\n\\t\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n\\t\\tData      any    `json:\\\"data\\\"`\\n\\t}\\n\\n\\tvar all []treeEntry\\n\\tbyID := map[string]*treeEntry{}\\n\\tchildrenOf := map[string][]string{} // parent_id → [entry_ids]\\n\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\tte := treeEntry{\\n\\t\\t\\tEntryID:   e.EntryID,\\n\\t\\t\\tParentID:  e.ParentID,\\n\\t\\t\\tType:      e.Type,\\n\\t\\t\\tDepth:     e.Depth,\\n\\t\\t\\tSortOrder: e.SortOrder,\\n\\t\\t\\tVersion:   e.Version,\\n\\t\\t\\tCreatedAt: e.CreatedAt,\\n\\t\\t}\\n\\n\\t\\t// Decrypt data\\n\\t\\tif len(e.Data) \\u003e 0 {\\n\\t\\t\\tdataText, err := lib.Unpack(projectKey, e.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar parsed any\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026parsed) == nil {\\n\\t\\t\\t\\t\\tte.Data = parsed\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\n\\t\\tall = append(all, te)\\n\\t\\tbyID[te.EntryID] = \\u0026all[len(all)-1]\\n\\t\\tchildrenOf[te.ParentID] = append(childrenOf[te.ParentID], te.EntryID)\\n\\t}\\n\\n\\t// Build set of hidden request_list IDs based on visibility\\n\\thiddenLists := map[string]bool{}\\n\\tif !isIBRole {\\n\\t\\tfor _, te := range all {\\n\\t\\t\\tif te.Type == lib.TypeRequestList {\\n\\t\\t\\t\\tif dataMap, ok := te.Data.(map[string]any); ok {\\n\\t\\t\\t\\t\\tif visOrgID, ok := dataMap[\\\"visibility_org_id\\\"].(string); ok \\u0026\\u0026 visOrgID != \\\"\\\" {\\n\\t\\t\\t\\t\\t\\tif visOrgID != actorOrgID {\\n\\t\\t\\t\\t\\t\\t\\thiddenLists[te.EntryID] = true\\n\\t\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\\n\\t// Get answer link counts\\n\\tlinkCounts, _ := lib.AnswerLinkCountsByProject(h.DB, projectID)\\n\\n\\t// DFS to produce tree order\\n\\ttype treeItem struct {\\n\\t\\tEntryID       string `json:\\\"entry_id\\\"`\\n\\t\\tParentID      string `json:\\\"parent_id\\\"`\\n\\t\\tType          string `json:\\\"type\\\"`\\n\\t\\tDepth         int    `json:\\\"depth\\\"`\\n\\t\\tSortOrder     int    `json:\\\"sort_order\\\"`\\n\\t\\tVersion       int    `json:\\\"version\\\"`\\n\\t\\tCreatedAt     int64  `json:\\\"created_at\\\"`\\n\\t\\tData          any    `json:\\\"data\\\"`\\n\\t\\tAnswerCount   int    `json:\\\"answer_count\\\"`\\n\\t\\tChildrenCount int    `json:\\\"children_count\\\"`\\n\\t}\\n\\n\\tvar result []treeItem\\n\\tvar dfs func(parentID string)\\n\\tdfs = func(parentID string) {\\n\\t\\tchildren := childrenOf[parentID]\\n\\t\\tfor _, cid := range children {\\n\\t\\t\\tte := byID[cid]\\n\\t\\t\\tif te == nil {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\t// Skip hidden request_lists and their subtrees\\n\\t\\t\\tif hiddenLists[te.EntryID] {\\n\\t\\t\\t\\tcontinue\\n\\t\\t\\t}\\n\\t\\t\\titem := treeItem{\\n\\t\\t\\t\\tEntryID:       te.EntryID,\\n\\t\\t\\t\\tParentID:      te.ParentID,\\n\\t\\t\\t\\tType:          te.Type,\\n\\t\\t\\t\\tDepth:         te.Depth,\\n\\t\\t\\t\\tSortOrder:     te.SortOrder,\\n\\t\\t\\t\\tVersion:       te.Version,\\n\\t\\t\\t\\tCreatedAt:     te.CreatedAt,\\n\\t\\t\\t\\tData:          te.Data,\\n\\t\\t\\t\\tAnswerCount:   linkCounts[te.EntryID],\\n\\t\\t\\t\\tChildrenCount: len(childrenOf[te.EntryID]),\\n\\t\\t\\t}\\n\\t\\t\\tresult = append(result, item)\\n\\t\\t\\tdfs(te.EntryID)\\n\\t\\t}\\n\\t}\\n\\n\\t// Start DFS from project (request_lists have parent=projectID)\\n\\tdfs(projectID)\\n\\n\\tif result == nil {\\n\\t\\tresult = []treeItem{}\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// MoveEntry handles POST /api/projects/{projectID}/entries/{entryID}/move\\n// Body: {\\\"parent_id\\\": \\\"...\\\", \\\"position\\\": 0}\\nfunc (h *Handlers) MoveEntry(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\tentryID := chi.URLParam(r, \\\"entryID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tParentID string `json:\\\"parent_id\\\"`\\n\\t\\tPosition int    `json:\\\"position\\\"` // 0-based position among siblings\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get the entry being moved\\n\\tentry, err := lib.EntryByID(h.DB, h.Cfg, entryID)\\n\\tif err != nil || entry == nil {\\n\\t\\tErrorResponse(w, http.StatusNotFound, \\\"not_found\\\", \\\"Entry not found\\\")\\n\\t\\treturn\\n\\t}\\n\\tif entry.ProjectID != projectID {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Entry not in project\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Get new parent to determine depth\\n\\tnewDepth := 1\\n\\tif req.ParentID != projectID {\\n\\t\\tparent, err := lib.EntryByID(h.DB, h.Cfg, req.ParentID)\\n\\t\\tif err != nil || parent == nil {\\n\\t\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_parent\\\", \\\"Parent not found\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\tnewDepth = parent.Depth + 1\\n\\t}\\n\\n\\toldParentID := entry.ParentID\\n\\n\\t// Assign sort_order: position * 1000\\n\\tsortOrder := (req.Position + 1) * 1000\\n\\n\\t// Move the entry\\n\\tif err := lib.EntryMoveSort(h.DB, entryID, req.ParentID, newDepth, sortOrder); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to move entry\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Renumber siblings at new parent\\n\\tlib.RenumberSiblings(h.DB, req.ParentID)\\n\\n\\t// Renumber siblings at old parent if different\\n\\tif oldParentID != req.ParentID {\\n\\t\\tlib.RenumberSiblings(h.DB, oldParentID)\\n\\t}\\n\\n\\t// Update depth of all descendants recursively\\n\\th.updateDescendantDepths(entryID, newDepth)\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"moved\\\"})\\n}\\n\\n// updateDescendantDepths recursively updates depth of children.\\nfunc (h *Handlers) updateDescendantDepths(parentID string, parentDepth int) {\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id FROM entries WHERE parent_id = ? AND deleted_at IS NULL`, parentID,\\n\\t)\\n\\tif err != nil {\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tvar ids []string\\n\\tfor rows.Next() {\\n\\t\\tvar id string\\n\\t\\trows.Scan(\\u0026id)\\n\\t\\tids = append(ids, id)\\n\\t}\\n\\n\\tchildDepth := parentDepth + 1\\n\\tfor _, id := range ids {\\n\\t\\th.DB.Conn.Exec(`UPDATE entries SET depth = ? WHERE entry_id = ?`, childDepth, id)\\n\\t\\th.updateDescendantDepths(id, childDepth)\\n\\t}\\n}\\n\\n// ListAnswerLinks handles GET /api/projects/{projectID}/requests/{requestID}/links\\nfunc (h *Handlers) ListAnswerLinks(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\trequestID := chi.URLParam(r, \\\"requestID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tlinks, err := lib.AnswerLinksByRequest(h.DB, requestID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list answer links\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Enrich with answer entry data\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\n\\ttype LinkItem struct {\\n\\t\\tAnswerID  string `json:\\\"answer_id\\\"`\\n\\t\\tRequestID string `json:\\\"request_id\\\"`\\n\\t\\tLinkedAt  int64  `json:\\\"linked_at\\\"`\\n\\t\\tStatus    string `json:\\\"status\\\"`\\n\\t\\tData      any    `json:\\\"data\\\"`\\n\\t}\\n\\n\\tvar result []LinkItem\\n\\tfor _, l := range links {\\n\\t\\titem := LinkItem{\\n\\t\\t\\tAnswerID:  l.AnswerID,\\n\\t\\t\\tRequestID: l.RequestID,\\n\\t\\t\\tLinkedAt:  l.LinkedAt,\\n\\t\\t\\tStatus:    l.Status,\\n\\t\\t}\\n\\t\\t// Get answer entry data\\n\\t\\tanswerEntry, err := lib.EntryByID(h.DB, h.Cfg, l.AnswerID)\\n\\t\\tif err == nil \\u0026\\u0026 answerEntry != nil \\u0026\\u0026 answerEntry.DataText != \\\"\\\" {\\n\\t\\t\\tvar parsed any\\n\\t\\t\\tif json.Unmarshal([]byte(answerEntry.DataText), \\u0026parsed) == nil {\\n\\t\\t\\t\\titem.Data = parsed\\n\\t\\t\\t}\\n\\t\\t} else if err == nil \\u0026\\u0026 answerEntry != nil \\u0026\\u0026 len(answerEntry.Data) \\u003e 0 {\\n\\t\\t\\tdataText, err := lib.Unpack(projectKey, answerEntry.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar parsed any\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026parsed) == nil {\\n\\t\\t\\t\\t\\titem.Data = parsed\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tresult = append(result, item)\\n\\t}\\n\\n\\tif result == nil {\\n\\t\\tresult = []LinkItem{}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// CreateAnswerLink handles POST /api/projects/{projectID}/requests/{requestID}/links\\nfunc (h *Handlers) CreateAnswerLink(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\trequestID := chi.URLParam(r, \\\"requestID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\tAnswerID string `json:\\\"answer_id\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil || req.AnswerID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"answer_id required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif err := lib.AnswerLinkCreate(h.DB, req.AnswerID, requestID, actorID); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create link\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]string{\\\"status\\\": \\\"linked\\\"})\\n}\\n\\n// DeleteAnswerLink handles DELETE /api/projects/{projectID}/requests/{requestID}/links/{answerID}\\nfunc (h *Handlers) DeleteAnswerLink(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\trequestID := chi.URLParam(r, \\\"requestID\\\")\\n\\tanswerID := chi.URLParam(r, \\\"answerID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif err := lib.AnswerLinkDelete(h.DB, answerID, requestID); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to delete link\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusOK, map[string]string{\\\"status\\\": \\\"unlinked\\\"})\\n\\t_ = actorID // actorID checked above\\n}\\n\\n// ListAnswers handles GET /api/projects/{projectID}/answers\\n// Returns all answer entries for the project, for use in the link picker.\\nfunc (h *Handlers) ListAnswers(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessRead(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tq := r.URL.Query().Get(\\\"q\\\")\\n\\n\\trows, err := h.DB.Conn.Query(\\n\\t\\t`SELECT entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t        search_key, search_key2, summary, data, stage,\\n\\t\\t        assignee_id, return_to_id, origin_id,\\n\\t\\t        version, deleted_at, deleted_by, key_version,\\n\\t\\t        created_at, updated_at, created_by\\n\\t\\t FROM entries WHERE project_id = ? AND type = 'answer' AND deleted_at IS NULL\\n\\t\\t ORDER BY created_at DESC`, projectID,\\n\\t)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to list answers\\\")\\n\\t\\treturn\\n\\t}\\n\\tdefer rows.Close()\\n\\n\\tprojectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\n\\ttype AnswerItem struct {\\n\\t\\tEntryID   string `json:\\\"entry_id\\\"`\\n\\t\\tCreatedAt int64  `json:\\\"created_at\\\"`\\n\\t\\tData      any    `json:\\\"data\\\"`\\n\\t}\\n\\n\\tvar result []AnswerItem\\n\\tfor rows.Next() {\\n\\t\\tvar e lib.Entry\\n\\t\\terr := rows.Scan(\\n\\t\\t\\t\\u0026e.EntryID, \\u0026e.ProjectID, \\u0026e.ParentID, \\u0026e.Type, \\u0026e.Depth, \\u0026e.SortOrder,\\n\\t\\t\\t\\u0026e.SearchKey, \\u0026e.SearchKey2, \\u0026e.Summary, \\u0026e.Data, \\u0026e.Stage,\\n\\t\\t\\t\\u0026e.AssigneeID, \\u0026e.ReturnToID, \\u0026e.OriginID,\\n\\t\\t\\t\\u0026e.Version, \\u0026e.DeletedAt, \\u0026e.DeletedBy, \\u0026e.KeyVersion,\\n\\t\\t\\t\\u0026e.CreatedAt, \\u0026e.UpdatedAt, \\u0026e.CreatedBy,\\n\\t\\t)\\n\\t\\tif err != nil {\\n\\t\\t\\tcontinue\\n\\t\\t}\\n\\n\\t\\titem := AnswerItem{\\n\\t\\t\\tEntryID:   e.EntryID,\\n\\t\\t\\tCreatedAt: e.CreatedAt,\\n\\t\\t}\\n\\n\\t\\tif len(e.Data) \\u003e 0 {\\n\\t\\t\\tdataText, err := lib.Unpack(projectKey, e.Data)\\n\\t\\t\\tif err == nil {\\n\\t\\t\\t\\tvar parsed any\\n\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026parsed) == nil {\\n\\t\\t\\t\\t\\titem.Data = parsed\\n\\t\\t\\t\\t\\t// Filter by search query\\n\\t\\t\\t\\t\\tif q != \\\"\\\" \\u0026\\u0026 !strings.Contains(strings.ToLower(dataText), strings.ToLower(q)) {\\n\\t\\t\\t\\t\\t\\tcontinue\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\n\\t\\tresult = append(result, item)\\n\\t}\\n\\n\\tif result == nil {\\n\\t\\tresult = []AnswerItem{}\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, result)\\n}\\n\\n// containsAny checks if s contains any of the given substrings\\nfunc containsAny(s string, subs ...string) bool {\\n\\tfor _, sub := range subs {\\n\\t\\tif strings.Contains(s, sub) {\\n\\t\\t\\treturn true\\n\\t\\t}\\n\\t}\\n\\treturn false\\n}\\n\\n// UpdateOrg handles PUT /api/orgs/{orgId} — update org name, domains, role, website\\n\\n// SetTestRole handles PUT /api/admin/test-role — super admin sets their test role for the current session.\\nfunc (h *Handlers) SetTestRole(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tisSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)\\n\\tif !isSuperAdmin {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Super admin only\\\")\\n\\t\\treturn\\n\\t}\\n\\tsessionID := SessionIDFromContext(r.Context())\\n\\tif sessionID == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"no_session\\\", \\\"No session found\\\")\\n\\t\\treturn\\n\\t}\\n\\tvar req struct {\\n\\t\\tRole string `json:\\\"role\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\tvalidRoles := map[string]bool{\\\"\\\": true, \\\"ib\\\": true, \\\"buyer\\\": true, \\\"seller\\\": true, \\\"advisor\\\": true}\\n\\tif !validRoles[req.Role] {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_role\\\", \\\"Role must be one of: ib, buyer, seller, advisor (or empty to reset)\\\")\\n\\t\\treturn\\n\\t}\\n\\tif err := lib.SetSessionTestRole(h.DB, sessionID, req.Role); err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to set test role\\\")\\n\\t\\treturn\\n\\t}\\n\\tJSONResponse(w, http.StatusOK, map[string]any{\\\"ok\\\": true, \\\"test_role\\\": req.Role})\\n}\\n\\n// AddOrgToDeal handles POST /api/projects/{projectID}/orgs/add — creates org (if new) + deal_org + members in one shot.\\nfunc (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {\\n\\tactorID := UserIDFromContext(r.Context())\\n\\tprojectID := chi.URLParam(r, \\\"projectID\\\")\\n\\n\\tif err := lib.CheckAccessWrite(h.DB, actorID, projectID, \\\"\\\"); err != nil {\\n\\t\\tErrorResponse(w, http.StatusForbidden, \\\"access_denied\\\", \\\"Access denied\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tvar req struct {\\n\\t\\t// Org info\\n\\t\\tName        string   `json:\\\"name\\\"`\\n\\t\\tDomains     []string `json:\\\"domains\\\"`\\n\\t\\tRole        string   `json:\\\"role\\\"` // seller | buyer | ib | advisor\\n\\t\\tLogo        string   `json:\\\"logo\\\"`\\n\\t\\tWebsite     string   `json:\\\"website\\\"`\\n\\t\\tDescription string   `json:\\\"description\\\"`\\n\\t\\tIndustry    string   `json:\\\"industry\\\"`\\n\\t\\tPhone       string   `json:\\\"phone\\\"`\\n\\t\\tFax         string   `json:\\\"fax\\\"`\\n\\t\\tAddress     string   `json:\\\"address\\\"`\\n\\t\\tCity        string   `json:\\\"city\\\"`\\n\\t\\tState       string   `json:\\\"state\\\"`\\n\\t\\tCountry     string   `json:\\\"country\\\"`\\n\\t\\tFounded     string   `json:\\\"founded\\\"`\\n\\t\\tLinkedIn    string   `json:\\\"linkedin\\\"`\\n\\t\\t// Selected members\\n\\t\\tMembers []lib.DealOrgMember `json:\\\"members\\\"`\\n\\t\\t// Deal org settings\\n\\t\\tDomainLock bool `json:\\\"domain_lock\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tif req.Name == \\\"\\\" {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Organization name required\\\")\\n\\t\\treturn\\n\\t}\\n\\t// Derive domain from website if not provided\\n\\tif len(req.Domains) == 0 \\u0026\\u0026 req.Website != \\\"\\\" {\\n\\t\\tdomain := req.Website\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"https://\\\")\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"http://\\\")\\n\\t\\tdomain = strings.TrimPrefix(domain, \\\"www.\\\")\\n\\t\\tif idx := strings.Index(domain, \\\"/\\\"); idx != -1 {\\n\\t\\t\\tdomain = domain[:idx]\\n\\t\\t}\\n\\t\\tif domain != \\\"\\\" {\\n\\t\\t\\treq.Domains = []string{strings.ToLower(strings.TrimSpace(domain))}\\n\\t\\t}\\n\\t}\\n\\t// Name is required; domain is best-effort\\n\\tif len(req.Domains) == 0 {\\n\\t\\treq.Domains = []string{\\\"unknown.invalid\\\"}\\n\\t}\\n\\tvalidRoles := map[string]bool{\\\"seller\\\": true, \\\"buyer\\\": true, \\\"ib\\\": true, \\\"advisor\\\": true}\\n\\tif req.Role == \\\"\\\" || !validRoles[req.Role] {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_role\\\", \\\"Role must be one of: seller, buyer, ib, advisor\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tfor i := range req.Domains {\\n\\t\\treq.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))\\n\\t}\\n\\n\\tnow := time.Now().UnixMilli()\\n\\n\\t// Step 1: Check if an organization with a matching domain already exists\\n\\torgID := \\\"\\\"\\n\\texistingOrgs, _ := h.queryOrgsByType(\\\"\\\")\\n\\tfor _, org := range existingOrgs {\\n\\t\\torgMap := h.orgToMap(\\u0026org)\\n\\t\\tif domains, ok := orgMap[\\\"domains\\\"].([]string); ok {\\n\\t\\t\\tfor _, d := range domains {\\n\\t\\t\\t\\tfor _, rd := range req.Domains {\\n\\t\\t\\t\\t\\tif strings.EqualFold(d, rd) {\\n\\t\\t\\t\\t\\t\\torgID = org.EntryID\\n\\t\\t\\t\\t\\t\\tbreak\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t}\\n\\t\\t\\t\\tif orgID != \\\"\\\" {\\n\\t\\t\\t\\t\\tbreak\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t}\\n\\t\\tif orgID != \\\"\\\" {\\n\\t\\t\\tlog.Printf(\\\"reusing existing org %s for domain %v\\\", orgID, req.Domains)\\n\\t\\t\\tbreak\\n\\t\\t}\\n\\t}\\n\\n\\t// Create new org if no match found\\n\\tif orgID == \\\"\\\" {\\n\\t\\torgID = uuid.New().String()\\n\\t\\torgData := lib.OrgData{\\n\\t\\t\\tName:        req.Name,\\n\\t\\t\\tDomains:     req.Domains,\\n\\t\\t\\tRole:        req.Role,\\n\\t\\t\\tLogo:        req.Logo,\\n\\t\\t\\tWebsite:     req.Website,\\n\\t\\t\\tDescription: req.Description,\\n\\t\\t\\tIndustry:    req.Industry,\\n\\t\\t\\tPhone:       req.Phone,\\n\\t\\t\\tFax:         req.Fax,\\n\\t\\t\\tAddress:     req.Address,\\n\\t\\t\\tCity:        req.City,\\n\\t\\t\\tState:       req.State,\\n\\t\\t\\tCountry:     req.Country,\\n\\t\\t\\tFounded:     req.Founded,\\n\\t\\t\\tLinkedIn:    req.LinkedIn,\\n\\t\\t}\\n\\t\\torgDataJSON, _ := json.Marshal(orgData)\\n\\n\\t\\torgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)\\n\\t\\tif err != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t\\torgSummary, _ := lib.Pack(orgKey, req.Name)\\n\\t\\torgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON))\\n\\n\\t\\t_, dbErr := h.DB.Conn.Exec(\\n\\t\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t\\t  created_at, updated_at, created_by)\\n\\t\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\t\\torgID, orgID, \\\"\\\", lib.TypeOrganization, 0, 0,\\n\\t\\t\\tnil, nil, orgSummary, orgDataPacked, lib.StageDataroom,\\n\\t\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\t\\t1, nil, nil, 1,\\n\\t\\t\\tnow, now, actorID,\\n\\t\\t)\\n\\t\\tif dbErr != nil {\\n\\t\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to create organization\\\")\\n\\t\\t\\treturn\\n\\t\\t}\\n\\t}\\n\\n\\t// Step 2: Create deal_org entry linking org to project\\n\\tdealOrgID := uuid.New().String()\\n\\tdealOrgData := lib.DealOrgData{\\n\\t\\tOrgID:      orgID,\\n\\t\\tRole:       req.Role,\\n\\t\\tDomainLock: req.DomainLock,\\n\\t\\tMembers:    req.Members,\\n\\t}\\n\\tdealOrgJSON, _ := json.Marshal(dealOrgData)\\n\\n\\tprojKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)\\n\\tif err != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Key derivation failed\\\")\\n\\t\\treturn\\n\\t}\\n\\tdealSummary, _ := lib.Pack(projKey, req.Name)\\n\\tdealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON))\\n\\n\\t_, dbErr2 := h.DB.Conn.Exec(\\n\\t\\t`INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order,\\n\\t\\t  search_key, search_key2, summary, data, stage,\\n\\t\\t  assignee_id, return_to_id, origin_id,\\n\\t\\t  version, deleted_at, deleted_by, key_version,\\n\\t\\t  created_at, updated_at, created_by)\\n\\t\\t VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,\\n\\t\\tdealOrgID, projectID, projectID, lib.TypeDealOrg, 1, 0,\\n\\t\\tnil, nil, dealSummary, dealDataPacked, lib.StagePreDataroom,\\n\\t\\t\\\"\\\", \\\"\\\", \\\"\\\",\\n\\t\\t1, nil, nil, 1,\\n\\t\\tnow, now, actorID,\\n\\t)\\n\\tif dbErr2 != nil {\\n\\t\\tErrorResponse(w, http.StatusInternalServerError, \\\"internal\\\", \\\"Failed to add organization to deal\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\tJSONResponse(w, http.StatusCreated, map[string]any{\\n\\t\\t\\\"org_id\\\":      orgID,\\n\\t\\t\\\"deal_org_id\\\": dealOrgID,\\n\\t\\t\\\"name\\\":        req.Name,\\n\\t\\t\\\"role\\\":        req.Role,\\n\\t\\t\\\"members\\\":     len(req.Members),\\n\\t})\\n}\\n\\n// ScrapeOrg handles POST /api/scrape/org — takes an email, scrapes the domain for org + people data.\\nfunc (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) {\\n\\tvar req struct {\\n\\t\\tEmail string `json:\\\"email\\\"`\\n\\t}\\n\\tif err := json.NewDecoder(r.Body).Decode(\\u0026req); err != nil {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"invalid_json\\\", \\\"Invalid request body\\\")\\n\\t\\treturn\\n\\t}\\n\\treq.Email = strings.TrimSpace(strings.ToLower(req.Email))\\n\\tif req.Email == \\\"\\\" || !strings.Contains(req.Email, \\\"@\\\") {\\n\\t\\tErrorResponse(w, http.StatusBadRequest, \\\"missing_fields\\\", \\\"Valid email required\\\")\\n\\t\\treturn\\n\\t}\\n\\n\\t// Extract domain from email\\n\\tdomain := strings.SplitN(req.Email, \\\"@\\\", 2)[1]\\n\\n\\t// Check if we already have an org with this domain\\n\\texistingOrgs, _ := h.queryOrgsByType(\\\"\\\")\\n\\tfor _, org := range existingOrgs {\\n\\t\\torgMap := h.orgToMap(\\u0026org)\\n\\t\\tif domains, ok := orgMap[\\\"domains\\\"].([]string); ok {\\n\\t\\t\\tfor _, d := range domains {\\n\\t\\t\\t\\tif strings.EqualFold(d, domain) {\\n\\t\\t\\t\\t\\t// Return existing org data in scrape format\\n\\t\\t\\t\\t\\texisting := \\u0026lib.ScrapedOrg{\\n\\t\\t\\t\\t\\t\\tName:        fmt.Sprintf(\\\"%v\\\", orgMap[\\\"name\\\"]),\\n\\t\\t\\t\\t\\t\\tDomain:      domain,\\n\\t\\t\\t\\t\\t\\tLogo:        fmt.Sprintf(\\\"%v\\\", orgMap[\\\"logo\\\"]),\\n\\t\\t\\t\\t\\t\\tDescription: fmt.Sprintf(\\\"%v\\\", orgMap[\\\"description\\\"]),\\n\\t\\t\\t\\t\\t\\tIndustry:    fmt.Sprintf(\\\"%v\\\", orgMap[\\\"industry\\\"]),\\n\\t\\t\\t\\t\\t\\tWebsite:     fmt.Sprintf(\\\"%v\\\", orgMap[\\\"website\\\"]),\\n\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t// Get members from deal_orgs that reference this org\\n\\t\\t\\t\\t\\trows, err := h.DB.Conn.Query(\\n\\t\\t\\t\\t\\t\\t`SELECT data, project_id FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`,\\n\\t\\t\\t\\t\\t)\\n\\t\\t\\t\\t\\tif err == nil {\\n\\t\\t\\t\\t\\t\\tdefer rows.Close()\\n\\t\\t\\t\\t\\t\\tfor rows.Next() {\\n\\t\\t\\t\\t\\t\\t\\tvar data []byte\\n\\t\\t\\t\\t\\t\\t\\tvar pid string\\n\\t\\t\\t\\t\\t\\t\\tif rows.Scan(\\u0026data, \\u0026pid) != nil || len(data) == 0 {\\n\\t\\t\\t\\t\\t\\t\\t\\tcontinue\\n\\t\\t\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t\\t\\tprojKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, pid)\\n\\t\\t\\t\\t\\t\\t\\tdataText, err := lib.Unpack(projKey, data)\\n\\t\\t\\t\\t\\t\\t\\tif err != nil {\\n\\t\\t\\t\\t\\t\\t\\t\\tcontinue\\n\\t\\t\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t\\t\\tvar dod lib.DealOrgData\\n\\t\\t\\t\\t\\t\\t\\tif json.Unmarshal([]byte(dataText), \\u0026dod) == nil \\u0026\\u0026 dod.OrgID == org.EntryID {\\n\\t\\t\\t\\t\\t\\t\\t\\tfor _, m := range dod.Members {\\n\\t\\t\\t\\t\\t\\t\\t\\t\\texisting.People = append(existing.People, lib.ScrapedPerson{\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tName:     m.Name,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tEmail:    m.Email,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tTitle:    m.Title,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tPhone:    m.Phone,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tPhoto:    m.Photo,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tBio:      m.Bio,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t\\tLinkedIn: m.LinkedIn,\\n\\t\\t\\t\\t\\t\\t\\t\\t\\t})\\n\\t\\t\\t\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t\\t\\t\\tbreak\\n\\t\\t\\t\\t\\t\\t\\t}\\n\\t\\t\\t\\t\\t\\t}\"}","is_error":false}},{"type":"tool_result","data":{"tool_call_id":"functions.view:5","name":"","content":"\u003cfile\u003e\n     1|-- Dealspace initial schema\n     2|-- All content fields (search_key, search_key2, summary, data) are encrypted blobs.\n     3|-- Plain fields (entry_id, project_id, parent_id, type, depth, stage, timestamps, IDs) are indexed.\n     4|\n     5|PRAGMA journal_mode = WAL;\n     6|PRAGMA foreign_keys = ON;\n     7|\n     8|-- Users\n     9|CREATE TABLE IF NOT EXISTS users (\n    10|    user_id    TEXT PRIMARY KEY,\n    11|    email      TEXT NOT NULL UNIQUE,\n    12|    name       TEXT NOT NULL,\n    13|    password   TEXT NOT NULL,           -- bcrypt hash\n    14|    org_id     TEXT NOT NULL DEFAULT '',\n    15|    org_name   TEXT NOT NULL DEFAULT '',\n    16|    mfa_secret TEXT NOT NULL DEFAULT '', -- TOTP secret, encrypted\n    17|    active     INTEGER NOT NULL DEFAULT 1,\n    18|    created_at INTEGER NOT NULL,\n    19|    updated_at INTEGER NOT NULL\n    20|);\n    21|CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);\n    22|CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id);\n    23|\n    24|-- Entries: the single tree\n    25|CREATE TABLE IF NOT EXISTS entries (\n    26|    entry_id     TEXT PRIMARY KEY,\n    27|    project_id   TEXT NOT NULL,\n    28|    parent_id    TEXT NOT NULL DEFAULT '',\n    29|    type         TEXT NOT NULL,\n    30|    depth        INTEGER NOT NULL DEFAULT 0,\n    31|    search_key   BLOB,                    -- blind index (HMAC-SHA256)\n    32|    search_key2  BLOB,                    -- blind index (HMAC-SHA256)\n    33|    summary      BLOB,                    -- packed: zstd + AES-256-GCM\n    34|    data         BLOB,                    -- packed: zstd + AES-256-GCM\n    35|    stage        TEXT NOT NULL DEFAULT 'pre_dataroom',\n    36|    assignee_id  TEXT NOT NULL DEFAULT '',\n    37|    return_to_id TEXT NOT NULL DEFAULT '',\n    38|    origin_id    TEXT NOT NULL DEFAULT '',\n    39|    version      INTEGER NOT NULL DEFAULT 1,\n    40|    deleted_at   INTEGER,\n    41|    deleted_by   TEXT,\n    42|    key_version  INTEGER NOT NULL DEFAULT 1,\n    43|    created_at   INTEGER NOT NULL,\n    44|    updated_at   INTEGER NOT NULL,\n    45|    created_by   TEXT NOT NULL\n    46|);\n    47|CREATE INDEX IF NOT EXISTS idx_entries_project    ON entries(project_id);\n    48|CREATE INDEX IF NOT EXISTS idx_entries_parent     ON entries(parent_id);\n    49|CREATE INDEX IF NOT EXISTS idx_entries_type       ON entries(type);\n    50|CREATE INDEX IF NOT EXISTS idx_entries_stage      ON entries(stage);\n    51|CREATE INDEX IF NOT EXISTS idx_entries_assignee   ON entries(assignee_id);\n    52|CREATE INDEX IF NOT EXISTS idx_entries_return     ON entries(return_to_id);\n    53|CREATE INDEX IF NOT EXISTS idx_entries_origin     ON entries(origin_id);\n    54|CREATE INDEX IF NOT EXISTS idx_entries_search_key ON entries(search_key);\n    55|CREATE INDEX IF NOT EXISTS idx_entries_deleted    ON entries(deleted_at);\n    56|\n    57|-- Answer ↔ Request links (many-to-many)\n    58|CREATE TABLE IF NOT EXISTS answer_links (\n    59|    answer_id     TEXT NOT NULL REFERENCES entries(entry_id),\n    60|    request_id    TEXT NOT NULL REFERENCES entries(entry_id),\n    61|    linked_by     TEXT NOT NULL,\n    62|    linked_at     INTEGER NOT NULL,\n    63|    confirmed     INTEGER NOT NULL DEFAULT 0,\n    64|    ai_score      REAL,\n    65|    status        TEXT NOT NULL DEFAULT 'pending',  -- pending | confirmed | rejected\n    66|    reviewed_by   TEXT,\n    67|    reviewed_at   INTEGER,\n    68|    reject_reason TEXT,\n    69|    PRIMARY KEY (answer_id, request_id)\n    70|);\n    71|\n    72|-- Access / RBAC\n    73|CREATE TABLE IF NOT EXISTS access (\n    74|    id            TEXT PRIMARY KEY,\n    75|    project_id    TEXT NOT NULL,\n    76|    workstream_id TEXT,\n    77|    user_id       TEXT NOT NULL,\n    78|    role          TEXT NOT NULL,\n    79|    ops           TEXT NOT NULL,\n    80|    can_grant     INTEGER NOT NULL DEFAULT 0,\n    81|    granted_by    TEXT NOT NULL,\n    82|    granted_at    INTEGER NOT NULL,\n    83|    revoked_at    INTEGER,\n    84|    revoked_by    TEXT\n    85|);\n    86|CREATE INDEX IF NOT EXISTS idx_access_user    ON access(user_id);\n    87|CREATE INDEX IF NOT EXISTS idx_access_project ON access(project_id);\n    88|\n    89|-- Entry events (workflow thread)\n    90|CREATE TABLE IF NOT EXISTS entry_events (\n    91|    id       TEXT PRIMARY KEY,\n    92|    entry_id TEXT NOT NULL REFERENCES entries(entry_id),\n    93|    actor_id TEXT NOT NULL,\n    94|    channel  TEXT NOT NULL,\n    95|    action   BLOB NOT NULL,  -- packed\n    96|    data     BLOB NOT NULL,  -- packed\n    97|    ts       INTEGER NOT NULL\n    98|);\n    99|CREATE INDEX IF NOT EXISTS idx_events_entry ON entry_events(entry_id);\n   100|CREATE INDEX IF NOT EXISTS idx_events_actor ON entry_events(actor_id);\n   101|\n   102|-- Channel threads (email/Slack/Teams → entry mapping)\n   103|CREATE TABLE IF NOT EXISTS channel_threads (\n   104|    id         TEXT PRIMARY KEY,\n   105|    entry_id   TEXT NOT NULL REFERENCES entries(entry_id),\n   106|    channel    TEXT NOT NULL,\n   107|    thread_id  TEXT NOT NULL,\n   108|    project_id TEXT NOT NULL,\n   109|    UNIQUE(channel, thread_id)\n   110|);\n   111|\n   112|-- Embeddings (AI matching)\n   113|CREATE TABLE IF NOT EXISTS embeddings (\n   114|    entry_id TEXT PRIMARY KEY REFERENCES entries(entry_id),\n   115|    vector   BLOB NOT NULL\n   116|);\n   117|\n   118|-- Audit log\n   119|CREATE TABLE IF NOT EXISTS audit (\n   120|    id         TEXT PRIMARY KEY,\n   121|    project_id TEXT NOT NULL,\n   122|    actor_id   TEXT NOT NULL,\n   123|    action     BLOB NOT NULL,   -- packed\n   124|    target_id  TEXT,\n   125|    details    BLOB,            -- packed\n   126|    ip         TEXT,\n   127|    ts         INTEGER NOT NULL\n   128|);\n   129|CREATE INDEX IF NOT EXISTS idx_audit_project ON audit(project_id);\n   130|CREATE INDEX IF NOT EXISTS idx_audit_actor   ON audit(actor_id);\n   131|CREATE INDEX IF NOT EXISTS idx_audit_ts      ON audit(ts);\n   132|\n   133|-- Sessions\n   134|CREATE TABLE IF NOT EXISTS sessions (\n   135|    id           TEXT PRIMARY KEY,\n   136|    user_id      TEXT NOT NULL REFERENCES users(user_id),\n   137|    fingerprint  TEXT NOT NULL DEFAULT '',\n   138|    created_at   INTEGER NOT NULL,\n   139|    expires_at   INTEGER NOT NULL,\n   140|    revoked      INTEGER NOT NULL DEFAULT 0\n   141|);\n   142|CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);\n   143|\n   144|-- Auth challenges (email OTP)\n   145|CREATE TABLE IF NOT EXISTS challenges (\n   146|    challenge_id TEXT PRIMARY KEY,\n   147|    email        TEXT NOT NULL,\n   148|    code         TEXT NOT NULL,\n   149|    created_at   INTEGER NOT NULL,\n   150|    expires_at   INTEGER NOT NULL,\n   151|    used         INTEGER NOT NULL DEFAULT 0\n   152|);\n   153|CREATE INDEX IF NOT EXISTS idx_challenges_email ON challenges(email);\n   154|\n   155|-- Broadcasts (idempotency)\n   156|CREATE TABLE IF NOT EXISTS broadcasts (\n   157|    id           TEXT PRIMARY KEY,\n   158|    answer_id    TEXT NOT NULL,\n   159|    request_id   TEXT NOT NULL,\n   160|    recipient_id TEXT NOT NULL,\n   161|    sent_at      INTEGER NOT NULL,\n   162|    UNIQUE(answer_id, request_id, recipient_id)\n   163|);\n\u003c/file\u003e\n","metadata":"{\"file_path\":\"/home/johan/dev/dealspace/migrations/001_initial.sql\",\"content\":\"-- Dealspace initial schema\\n-- All content fields (search_key, search_key2, summary, data) are encrypted blobs.\\n-- Plain fields (entry_id, project_id, parent_id, type, depth, stage, timestamps, IDs) are indexed.\\n\\nPRAGMA journal_mode = WAL;\\nPRAGMA foreign_keys = ON;\\n\\n-- Users\\nCREATE TABLE IF NOT EXISTS users (\\n    user_id    TEXT PRIMARY KEY,\\n    email      TEXT NOT NULL UNIQUE,\\n    name       TEXT NOT NULL,\\n    password   TEXT NOT NULL,           -- bcrypt hash\\n    org_id     TEXT NOT NULL DEFAULT '',\\n    org_name   TEXT NOT NULL DEFAULT '',\\n    mfa_secret TEXT NOT NULL DEFAULT '', -- TOTP secret, encrypted\\n    active     INTEGER NOT NULL DEFAULT 1,\\n    created_at INTEGER NOT NULL,\\n    updated_at INTEGER NOT NULL\\n);\\nCREATE INDEX IF NOT EXISTS idx_users_email ON users(email);\\nCREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id);\\n\\n-- Entries: the single tree\\nCREATE TABLE IF NOT EXISTS entries (\\n    entry_id     TEXT PRIMARY KEY,\\n    project_id   TEXT NOT NULL,\\n    parent_id    TEXT NOT NULL DEFAULT '',\\n    type         TEXT NOT NULL,\\n    depth        INTEGER NOT NULL DEFAULT 0,\\n    search_key   BLOB,                    -- blind index (HMAC-SHA256)\\n    search_key2  BLOB,                    -- blind index (HMAC-SHA256)\\n    summary      BLOB,                    -- packed: zstd + AES-256-GCM\\n    data         BLOB,                    -- packed: zstd + AES-256-GCM\\n    stage        TEXT NOT NULL DEFAULT 'pre_dataroom',\\n    assignee_id  TEXT NOT NULL DEFAULT '',\\n    return_to_id TEXT NOT NULL DEFAULT '',\\n    origin_id    TEXT NOT NULL DEFAULT '',\\n    version      INTEGER NOT NULL DEFAULT 1,\\n    deleted_at   INTEGER,\\n    deleted_by   TEXT,\\n    key_version  INTEGER NOT NULL DEFAULT 1,\\n    created_at   INTEGER NOT NULL,\\n    updated_at   INTEGER NOT NULL,\\n    created_by   TEXT NOT NULL\\n);\\nCREATE INDEX IF NOT EXISTS idx_entries_project    ON entries(project_id);\\nCREATE INDEX IF NOT EXISTS idx_entries_parent     ON entries(parent_id);\\nCREATE INDEX IF NOT EXISTS idx_entries_type       ON entries(type);\\nCREATE INDEX IF NOT EXISTS idx_entries_stage      ON entries(stage);\\nCREATE INDEX IF NOT EXISTS idx_entries_assignee   ON entries(assignee_id);\\nCREATE INDEX IF NOT EXISTS idx_entries_return     ON entries(return_to_id);\\nCREATE INDEX IF NOT EXISTS idx_entries_origin     ON entries(origin_id);\\nCREATE INDEX IF NOT EXISTS idx_entries_search_key ON entries(search_key);\\nCREATE INDEX IF NOT EXISTS idx_entries_deleted    ON entries(deleted_at);\\n\\n-- Answer ↔ Request links (many-to-many)\\nCREATE TABLE IF NOT EXISTS answer_links (\\n    answer_id     TEXT NOT NULL REFERENCES entries(entry_id),\\n    request_id    TEXT NOT NULL REFERENCES entries(entry_id),\\n    linked_by     TEXT NOT NULL,\\n    linked_at     INTEGER NOT NULL,\\n    confirmed     INTEGER NOT NULL DEFAULT 0,\\n    ai_score      REAL,\\n    status        TEXT NOT NULL DEFAULT 'pending',  -- pending | confirmed | rejected\\n    reviewed_by   TEXT,\\n    reviewed_at   INTEGER,\\n    reject_reason TEXT,\\n    PRIMARY KEY (answer_id, request_id)\\n);\\n\\n-- Access / RBAC\\nCREATE TABLE IF NOT EXISTS access (\\n    id            TEXT PRIMARY KEY,\\n    project_id    TEXT NOT NULL,\\n    workstream_id TEXT,\\n    user_id       TEXT NOT NULL,\\n    role          TEXT NOT NULL,\\n    ops           TEXT NOT NULL,\\n    can_grant     INTEGER NOT NULL DEFAULT 0,\\n    granted_by    TEXT NOT NULL,\\n    granted_at    INTEGER NOT NULL,\\n    revoked_at    INTEGER,\\n    revoked_by    TEXT\\n);\\nCREATE INDEX IF NOT EXISTS idx_access_user    ON access(user_id);\\nCREATE INDEX IF NOT EXISTS idx_access_project ON access(project_id);\\n\\n-- Entry events (workflow thread)\\nCREATE TABLE IF NOT EXISTS entry_events (\\n    id       TEXT PRIMARY KEY,\\n    entry_id TEXT NOT NULL REFERENCES entries(entry_id),\\n    actor_id TEXT NOT NULL,\\n    channel  TEXT NOT NULL,\\n    action   BLOB NOT NULL,  -- packed\\n    data     BLOB NOT NULL,  -- packed\\n    ts       INTEGER NOT NULL\\n);\\nCREATE INDEX IF NOT EXISTS idx_events_entry ON entry_events(entry_id);\\nCREATE INDEX IF NOT EXISTS idx_events_actor ON entry_events(actor_id);\\n\\n-- Channel threads (email/Slack/Teams → entry mapping)\\nCREATE TABLE IF NOT EXISTS channel_threads (\\n    id         TEXT PRIMARY KEY,\\n    entry_id   TEXT NOT NULL REFERENCES entries(entry_id),\\n    channel    TEXT NOT NULL,\\n    thread_id  TEXT NOT NULL,\\n    project_id TEXT NOT NULL,\\n    UNIQUE(channel, thread_id)\\n);\\n\\n-- Embeddings (AI matching)\\nCREATE TABLE IF NOT EXISTS embeddings (\\n    entry_id TEXT PRIMARY KEY REFERENCES entries(entry_id),\\n    vector   BLOB NOT NULL\\n);\\n\\n-- Audit log\\nCREATE TABLE IF NOT EXISTS audit (\\n    id         TEXT PRIMARY KEY,\\n    project_id TEXT NOT NULL,\\n    actor_id   TEXT NOT NULL,\\n    action     BLOB NOT NULL,   -- packed\\n    target_id  TEXT,\\n    details    BLOB,            -- packed\\n    ip         TEXT,\\n    ts         INTEGER NOT NULL\\n);\\nCREATE INDEX IF NOT EXISTS idx_audit_project ON audit(project_id);\\nCREATE INDEX IF NOT EXISTS idx_audit_actor   ON audit(actor_id);\\nCREATE INDEX IF NOT EXISTS idx_audit_ts      ON audit(ts);\\n\\n-- Sessions\\nCREATE TABLE IF NOT EXISTS sessions (\\n    id           TEXT PRIMARY KEY,\\n    user_id      TEXT NOT NULL REFERENCES users(user_id),\\n    fingerprint  TEXT NOT NULL DEFAULT '',\\n    created_at   INTEGER NOT NULL,\\n    expires_at   INTEGER NOT NULL,\\n    revoked      INTEGER NOT NULL DEFAULT 0\\n);\\nCREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);\\n\\n-- Auth challenges (email OTP)\\nCREATE TABLE IF NOT EXISTS challenges (\\n    challenge_id TEXT PRIMARY KEY,\\n    email        TEXT NOT NULL,\\n    code         TEXT NOT NULL,\\n    created_at   INTEGER NOT NULL,\\n    expires_at   INTEGER NOT NULL,\\n    used         INTEGER NOT NULL DEFAULT 0\\n);\\nCREATE INDEX IF NOT EXISTS idx_challenges_email ON challenges(email);\\n\\n-- Broadcasts (idempotency)\\nCREATE TABLE IF NOT EXISTS broadcasts (\\n    id           TEXT PRIMARY KEY,\\n    answer_id    TEXT NOT NULL,\\n    request_id   TEXT NOT NULL,\\n    recipient_id TEXT NOT NULL,\\n    sent_at      INTEGER NOT NULL,\\n    UNIQUE(answer_id, request_id, recipient_id)\\n);\"}","is_error":false}},{"type":"tool_result","data":{"tool_call_id":"functions.ls:6","name":"","content":"- /home/johan/dev/dealspace/portal/templates/app/\n  - home/\n    - johan/\n      - dev/\n        - dealspace/\n          - portal/\n            - templates/\n              - app/\n                - orgs.html\n                - project.html\n                - projects.html\n                - request.html\n                - tasks.html\n","metadata":"{\"number_of_files\":5,\"truncated\":false}","is_error":false}},{"type":"finish","data":{"reason":"stop","time":0}}]


