Changeset 0.19.0.1 (#82)

This commit is contained in:
2026-02-28 23:46:23 +00:00
parent 091ce2af6a
commit 748f49bedd
30 changed files with 3873 additions and 151 deletions

View File

@@ -39,6 +39,7 @@ type Stores struct {
Groups GroupStore
ResourceGrants ResourceGrantStore
Memories MemoryStore
Projects ProjectStore
}
// =========================================

View File

@@ -0,0 +1,403 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── ProjectStore ───────────────────────────
type ProjectStore struct{}
func NewProjectStore() *ProjectStore { return &ProjectStore{} }
// ── CRUD ────────────────────────────────────
func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error {
return DB.QueryRowContext(ctx, `
INSERT INTO projects (name, description, color, icon, scope, owner_id, team_id, settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Color, p.Icon, p.Scope,
p.OwnerID, models.NullString(p.TeamID), ToJSON(p.Settings),
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) {
var p models.Project
var teamID sql.NullString
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.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 = $1`, id).Scan(
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
&p.OwnerID, &teamID, &p.IsArchived, &p.Settings,
&p.CreatedAt, &p.UpdatedAt,
&p.ChannelCount, &p.KBCount, &p.NoteCount,
)
if err != nil {
return nil, err
}
p.TeamID = NullableStringPtr(teamID)
return &p, nil
}
func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error {
b := NewUpdate("projects")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
if patch.Color != nil {
b.Set("color", *patch.Color)
}
if patch.Icon != nil {
b.Set("icon", *patch.Icon)
}
if patch.IsArchived != nil {
b.Set("is_archived", *patch.IsArchived)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
res, err := b.Exec(DB)
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 {
// CASCADE handles junction tables.
// Channels get project_id set to NULL via ON DELETE SET NULL.
res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = $1", 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) {
// User sees: their personal projects + team projects for their teams + global projects
q := `
SELECT p.id, p.name, p.description, p.color, p.icon, p.scope,
p.owner_id, p.team_id, p.is_archived, 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 = $1)
OR p.scope = 'global'`
args := []interface{}{userID}
if len(teamIDs) > 0 {
q += fmt.Sprintf(`
OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1)
args = append(args, pq.Array(teamIDs))
}
q += `)`
if !includeArchived {
q += ` AND p.is_archived = false`
}
q += ` ORDER BY p.name`
return queryProjects(ctx, q, args...)
}
// ── Channel Association ─────────────────────
func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error {
// Atomic move: remove from old project (if any), add to new.
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Remove existing association (handles the "move" case)
tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = $1`, channelID)
// Insert new association
_, err = tx.ExecContext(ctx, `
INSERT INTO project_channels (project_id, channel_id, position)
VALUES ($1, $2, $3)`,
projectID, channelID, position)
if err != nil {
return err
}
// Update denormalized column
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = $1 WHERE id = $2`,
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 = $1 AND channel_id = $2`,
projectID, channelID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
// Clear denormalized column
_, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = $1`, 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 = $1
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 = $1
WHERE project_id = $2 AND channel_id = $3`,
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 ($1, $2, $3)
ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = EXCLUDED.auto_search`,
projectID, kbID, 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 = $1 AND kb_id = $2`,
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 project_id, kb_id, auto_search, added_at
FROM project_knowledge_bases
WHERE project_id = $1
ORDER BY added_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ProjectKB
for rows.Next() {
var pkb models.ProjectKB
if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt); err != nil {
return nil, err
}
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 = $1`, 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 ($1, $2)
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 = $1 AND note_id = $2`,
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 project_id, note_id, added_at
FROM project_notes
WHERE project_id = $1
ORDER BY 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); 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 = $1 AND (
(p.scope = 'personal' AND p.owner_id = $2)
OR p.scope = 'global'`
args := []interface{}{projectID, userID}
if len(teamIDs) > 0 {
q += fmt.Sprintf(`
OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1)
args = append(args, pq.Array(teamIDs))
}
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 = $1`,
channelID).Scan(&projectID)
if err == sql.ErrNoRows {
return "", nil // no project — not an error
}
return projectID, err
}
// ── Query Helper ────────────────────────────
func 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 sql.NullString
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
&p.OwnerID, &teamID, &p.IsArchived, &p.Settings,
&p.CreatedAt, &p.UpdatedAt,
&p.ChannelCount, &p.KBCount, &p.NoteCount,
); err != nil {
return nil, err
}
p.TeamID = NullableStringPtr(teamID)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
Projects: NewProjectStore(),
}
}

View File

@@ -0,0 +1,47 @@
package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// =========================================
// PROJECT STORE (v0.19.0)
// =========================================
type ProjectStore interface {
// CRUD
Create(ctx context.Context, p *models.Project) error
GetByID(ctx context.Context, id string) (*models.Project, error)
Update(ctx context.Context, id string, patch models.ProjectPatch) error
Delete(ctx context.Context, id string) error
// Listing
ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error)
// Channel association (atomic move: removes old project if any)
AddChannel(ctx context.Context, projectID, channelID string, position int) error
RemoveChannel(ctx context.Context, projectID, channelID string) error
ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error)
ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error
// KB association
AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error
RemoveKB(ctx context.Context, projectID, kbID string) error
ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error)
// Note association
AddNote(ctx context.Context, projectID, noteID string) error
RemoveNote(ctx context.Context, projectID, noteID string) error
ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error)
// Access check
UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error)
// Get project ID for a channel (used during note auto-association)
GetProjectIDForChannel(ctx context.Context, channelID string) (string, error)
// Get KB IDs bound to a project (used for virtual injection at completion)
GetKBIDs(ctx context.Context, projectID string) ([]string, error)
}

View File

@@ -0,0 +1,435 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/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 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.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, &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)
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 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.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 project_id, kb_id, auto_search, added_at
FROM project_knowledge_bases
WHERE project_id = ?
ORDER BY 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); 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 project_id, note_id, added_at
FROM project_notes
WHERE project_id = ?
ORDER BY 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); 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
}
// ── 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 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, &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)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
Projects: NewProjectStore(),
}
}