package sqlite import ( "database/sql" "fmt" "sort" "strings" "time" "github.com/google/uuid" "task/internal/project" ) // ProjectStore implements project.Store on SQLite. type ProjectStore struct { db *DB } // NewProjectStore returns a project store. func NewProjectStore(db *DB) *ProjectStore { return &ProjectStore{db: db} } // List returns all projects sorted by name. func (s *ProjectStore) List() ([]project.Project, error) { rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } defer rows.Close() var out []project.Project for rows.Next() { var p project.Project if err := rows.Scan(&p.ID, &p.Name); err != nil { return nil, err } out = append(out, p) } if out == nil { out = []project.Project{} } return out, rows.Err() } // Ensure creates the project if missing. func (s *ProjectStore) Ensure(id string) error { id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("project id is required") } now := formatTime(time.Now().UTC()) _, err := s.db.SQL.Exec(` INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`, id, id, now, now) if err != nil { return fmt.Errorf("ensure project %s: %w", id, err) } return nil } // Create creates a project. Empty id is replaced with a new UUID. func (s *ProjectStore) Create(id, name string) (project.Project, error) { id = strings.TrimSpace(id) name = strings.TrimSpace(name) if name == "" { return project.Project{}, fmt.Errorf("project name is required") } if id == "" { id = uuid.NewString() } now := formatTime(time.Now().UTC()) _, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`, id, name, now, now) if err != nil { return project.Project{}, fmt.Errorf("create project: %w", err) } return project.Project{ID: id, Name: name}, nil } // Rename updates the display name. func (s *ProjectStore) Rename(id, name string) error { name = strings.TrimSpace(name) if id == "" || name == "" { return fmt.Errorf("id and name are required") } res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`, name, formatTime(time.Now().UTC()), id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("project %s: not found", id) } return nil } // Delete removes a project and cascaded rows. func (s *ProjectStore) Delete(id string) error { if id == "" { return fmt.Errorf("project id is required") } res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete project %s: %w", id, err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("project %s: not found", id) } return nil } // Get returns one project. func (s *ProjectStore) Get(id string) (project.Project, error) { var p project.Project err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name) if err == sql.ErrNoRows { return project.Project{}, fmt.Errorf("project %s: not found", id) } if err != nil { return project.Project{}, err } return p, nil } // LoadMeta maps sync_backends (+ connection token) into project.Meta. func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) { if id == "" { return project.Meta{}, fmt.Errorf("project id is required") } var kind, baseURL, projectKey, query string var connID sql.NullString err := s.db.SQL.QueryRow(` SELECT kind, base_url, project_key, query, connection_id FROM sync_backends WHERE project_id = ?`, id). Scan(&kind, &baseURL, &projectKey, &query, &connID) if err == sql.ErrNoRows { return project.Meta{}, nil } if err != nil { return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err) } if kind != "youtrack" { return project.Meta{}, nil } yt := &project.YouTrack{ BaseURL: baseURL, ProjectKey: projectKey, Query: query, } if connID.Valid && connID.String != "" { token, err := s.db.connectionToken(connID.String) if err != nil { return project.Meta{}, err } yt.Token = token } return project.Meta{YouTrack: yt}, nil } // SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set). func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error { if id == "" { return fmt.Errorf("project id is required") } if err := s.Ensure(id); err != nil { return err } if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" { _, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id) return err } yt := meta.YouTrack var connID sql.NullString if strings.TrimSpace(yt.Token) != "" { cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token) if err != nil { return err } connID = sql.NullString{String: cid, Valid: true} } _, err := s.db.SQL.Exec(` INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id) VALUES(?, 'youtrack', ?, ?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET kind=excluded.kind, base_url=excluded.base_url, project_key=excluded.project_key, query=excluded.query, connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`, id, yt.BaseURL, yt.ProjectKey, yt.Query, connID) return err } // SyncBackend is the persisted per-project sync config (API-facing). type SyncBackend struct { ProjectID string `json:"projectId"` Kind string `json:"kind"` BaseURL string `json:"baseUrl"` ProjectKey string `json:"projectKey"` Query string `json:"query,omitempty"` ConnectionID string `json:"connectionId,omitempty"` } // GetSyncBackend returns sync config for a project. func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) { var b SyncBackend var connID sql.NullString err := s.db.SQL.QueryRow(` SELECT project_id, kind, base_url, project_key, query, connection_id FROM sync_backends WHERE project_id = ?`, projectID). Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID) if err == sql.ErrNoRows { return SyncBackend{ProjectID: projectID, Kind: "none"}, nil } if err != nil { return SyncBackend{}, err } if connID.Valid { b.ConnectionID = connID.String } return b, nil } // SetSyncBackend upserts sync config. func (s *ProjectStore) SetSyncBackend(b SyncBackend) error { if b.ProjectID == "" { return fmt.Errorf("projectId required") } if b.Kind == "" { b.Kind = "none" } var connID sql.NullString if b.ConnectionID != "" { connID = sql.NullString{String: b.ConnectionID, Valid: true} } _, err := s.db.SQL.Exec(` INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET kind=excluded.kind, base_url=excluded.base_url, project_key=excluded.project_key, query=excluded.query, connection_id=excluded.connection_id`, b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID) return err } // ListIDsForUser returns project IDs the user can access (all if admin). func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) { if isAdmin { return s.List() } rows, err := s.db.SQL.Query(` SELECT p.id, p.name FROM projects p INNER JOIN project_members m ON m.project_id = p.id WHERE m.user_id = ? ORDER BY p.name COLLATE NOCASE`, userID) if err != nil { return nil, err } defer rows.Close() var out []project.Project for rows.Next() { var p project.Project if err := rows.Scan(&p.ID, &p.Name); err != nil { return nil, err } out = append(out, p) } if out == nil { out = []project.Project{} } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, rows.Err() } func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) { // Reuse existing sync_backends connection if present. var existing sql.NullString _ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing) if existing.Valid && existing.String != "" { enc, err := db.EncryptToken(token) if err != nil { return "", err } _, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`, enc, baseURL, existing.String) if err != nil { return "", err } return existing.String, nil } id := "conn_" + projectID enc, err := db.EncryptToken(token) if err != nil { return "", err } _, err = db.SQL.Exec(` INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at) VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?) ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`, id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC())) if err != nil { return "", err } return id, nil } func (db *DB) connectionToken(id string) (string, error) { var blob []byte err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob) if err == sql.ErrNoRows { return "", fmt.Errorf("connection %s not found", id) } if err != nil { return "", err } return db.DecryptToken(blob) }