package sqlite import ( "context" "database/sql" "fmt" "time" "switchboard-core/models" "switchboard-core/store" ) // ── ProjectStore ─────────────────────────── type ProjectStore struct{} func NewProjectStore() *ProjectStore { return &ProjectStore{} } // ── CRUD ──────────────────────────────────── func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { p.ID = store.NewID() now := time.Now().UTC() p.CreatedAt = now p.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO projects (id, name, description, color, icon, scope, owner_id, team_id, is_archived, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, p.OwnerID, models.NullString(p.TeamID), boolToInt(p.IsArchived), ToJSON(p.Settings), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { var p models.Project var teamID, workspaceID sql.NullString var archived int err := DB.QueryRowContext(ctx, ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) FROM projects p WHERE p.id = ?`, id).Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt), &p.ChannelCount, &p.KBCount, &p.NoteCount, ) if err != nil { return nil, err } p.IsArchived = archived != 0 p.TeamID = NullableStringPtr(teamID) p.WorkspaceID = NullableStringPtr(workspaceID) return &p, nil } func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error { sets := []string{} args := []interface{}{} add := func(col string, val interface{}) { sets = append(sets, col+" = ?") args = append(args, val) } if patch.Name != nil { add("name", *patch.Name) } if patch.Description != nil { add("description", *patch.Description) } if patch.Color != nil { add("color", *patch.Color) } if patch.Icon != nil { add("icon", *patch.Icon) } if patch.IsArchived != nil { add("is_archived", boolToInt(*patch.IsArchived)) } if patch.WorkspaceID != nil { add("workspace_id", models.NullString(patch.WorkspaceID)) } if len(patch.Settings) > 0 { // Merge: read existing settings, overlay with patch values, write back. var existing models.JSONMap _ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = ?", id).Scan(&existing) if existing == nil { existing = make(models.JSONMap) } for k, v := range patch.Settings { existing[k] = v } add("settings", ToJSON(existing)) } if len(sets) == 0 { return nil } query := "UPDATE projects SET " for i, s := range sets { if i > 0 { query += ", " } query += s } query += " WHERE id = ?" args = append(args, id) res, err := DB.ExecContext(ctx, query, args...) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } func (s *ProjectStore) Delete(ctx context.Context, id string) error { // Clear denormalized project_id on channels first (SQLite FK cascade // handles junction tables, but ON DELETE SET NULL requires PRAGMA // foreign_keys = ON which may not cascade reliably on all builds). DB.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE project_id = ?`, id) res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = ?", id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } // ── Listing ───────────────────────────────── func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { q := ` SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, p.owner_id, p.team_id, p.is_archived, p.workspace_id, p.settings, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id) FROM projects p WHERE ( (p.scope = 'personal' AND p.owner_id = ?) OR p.scope = 'global'` args := []interface{}{userID} for _, tid := range teamIDs { q += ` OR (p.scope = 'team' AND p.team_id = ?)` args = append(args, tid) } q += `)` if !includeArchived { q += ` AND p.is_archived = 0` } q += ` ORDER BY p.name` return s.queryProjects(ctx, q, args...) } // ── Channel Association ───────────────────── func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Remove existing (atomic move) tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = ?`, channelID) _, err = tx.ExecContext(ctx, ` INSERT INTO project_channels (project_id, channel_id, position) VALUES (?, ?, ?)`, projectID, channelID, position) if err != nil { return err } _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = ? WHERE id = ?`, projectID, channelID) if err != nil { return err } return tx.Commit() } func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() res, err := tx.ExecContext(ctx, ` DELETE FROM project_channels WHERE project_id = ? AND channel_id = ?`, projectID, channelID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = ?`, channelID) if err != nil { return err } return tx.Commit() } func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) { rows, err := DB.QueryContext(ctx, ` SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at FROM project_channels WHERE project_id = ? ORDER BY position, added_at`, projectID) if err != nil { return nil, err } defer rows.Close() var result []models.ProjectChannel for rows.Next() { var pc models.ProjectChannel if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil { return nil, err } result = append(result, pc) } return result, rows.Err() } func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() for i, chID := range channelIDs { _, err := tx.ExecContext(ctx, ` UPDATE project_channels SET position = ? WHERE project_id = ? AND channel_id = ?`, i, projectID, chID) if err != nil { return err } } return tx.Commit() } // ── KB Association ────────────────────────── func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error { _, err := DB.ExecContext(ctx, ` INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search) VALUES (?, ?, ?) ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = excluded.auto_search`, projectID, kbID, boolToInt(autoSearch)) return err } func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error { res, err := DB.ExecContext(ctx, ` DELETE FROM project_knowledge_bases WHERE project_id = ? AND kb_id = ?`, projectID, kbID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { rows, err := DB.QueryContext(ctx, ` SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at, COALESCE(kb.name, '') AS name FROM project_knowledge_bases pk LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id WHERE pk.project_id = ? ORDER BY pk.added_at`, projectID) if err != nil { return nil, err } defer rows.Close() var result []models.ProjectKB for rows.Next() { var pkb models.ProjectKB var autoSearch int if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt, &pkb.Name); err != nil { return nil, err } pkb.AutoSearch = autoSearch != 0 result = append(result, pkb) } return result, rows.Err() } func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) { rows, err := DB.QueryContext(ctx, ` SELECT kb_id FROM project_knowledge_bases WHERE project_id = ?`, projectID) if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // ── Note Association ──────────────────────── func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error { _, err := DB.ExecContext(ctx, ` INSERT INTO project_notes (project_id, note_id) VALUES (?, ?) ON CONFLICT DO NOTHING`, projectID, noteID) return err } func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error { res, err := DB.ExecContext(ctx, ` DELETE FROM project_notes WHERE project_id = ? AND note_id = ?`, projectID, noteID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return sql.ErrNoRows } return nil } func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { rows, err := DB.QueryContext(ctx, ` SELECT pn.project_id, pn.note_id, pn.added_at, COALESCE(n.title, '') AS title FROM project_notes pn LEFT JOIN notes n ON n.id = pn.note_id WHERE pn.project_id = ? ORDER BY pn.added_at`, projectID) if err != nil { return nil, err } defer rows.Close() var result []models.ProjectNote for rows.Next() { var pn models.ProjectNote if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil { return nil, err } result = append(result, pn) } return result, rows.Err() } // ── Access Check ──────────────────────────── func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) { q := ` SELECT EXISTS( SELECT 1 FROM projects p WHERE p.id = ? AND ( (p.scope = 'personal' AND p.owner_id = ?) OR p.scope = 'global'` args := []interface{}{projectID, userID} for _, tid := range teamIDs { q += ` OR (p.scope = 'team' AND p.team_id = ?)` args = append(args, tid) } q += `))` var ok bool err := DB.QueryRowContext(ctx, q, args...).Scan(&ok) return ok, err } func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) { var projectID string err := DB.QueryRowContext(ctx, ` SELECT project_id FROM project_channels WHERE channel_id = ?`, channelID).Scan(&projectID) if err == sql.ErrNoRows { return "", nil } return projectID, err } // ── CS5c additions (v0.29.0) ────────────────────────────────────────── func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) { archivedFilter := "AND p.is_archived = 0" if includeArchived { archivedFilter = "" } rows, err := DB.QueryContext(ctx, fmt.Sprintf(` SELECT p.id, p.name, p.description, p.scope, p.owner_id, p.team_id, p.is_archived, p.created_at, p.updated_at, (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), COALESCE(u.username, '') FROM projects p LEFT JOIN users u ON u.id = p.owner_id WHERE 1=1 %s ORDER BY p.updated_at DESC`, archivedFilter)) if err != nil { return nil, err } defer rows.Close() results := make([]store.AdminProject, 0) for rows.Next() { var p store.AdminProject var teamID sql.NullString var archived int if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.Scope, &p.OwnerID, &teamID, &archived, st(&p.CreatedAt), st(&p.UpdatedAt), &p.ChannelCount, &p.KBCount, &p.NoteCount, &p.OwnerName, ); err != nil { return nil, err } p.IsArchived = archived != 0 p.TeamID = NullableStringPtr(teamID) results = append(results, p) } return results, rows.Err() } // ── Helpers ───────────────────────────────── func boolToInt(b bool) int { if b { return 1 } return 0 } func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("queryProjects: %w", err) } defer rows.Close() var result []models.Project for rows.Next() { var p models.Project var teamID, workspaceID sql.NullString var archived int if err := rows.Scan( &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, &p.OwnerID, &teamID, &archived, &workspaceID, &p.Settings, st(&p.CreatedAt), st(&p.UpdatedAt), &p.ChannelCount, &p.KBCount, &p.NoteCount, ); err != nil { return nil, err } p.IsArchived = archived != 0 p.TeamID = NullableStringPtr(teamID) p.WorkspaceID = NullableStringPtr(workspaceID) result = append(result, p) } return result, rows.Err() }