Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -30,6 +30,9 @@ func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]m
al.ip_address, al.user_agent, al.created_at`, "audit_log al")
b.Join("LEFT JOIN users u ON al.actor_id = u.id")
if opts.TeamID != "" {
b.Where("al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)", opts.TeamID)
}
if opts.ActorID != "" {
b.Where("al.actor_id = ?", opts.ActorID)
}

View File

@@ -281,3 +281,58 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
}
return result, rows.Err()
}
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) {
var capsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT capabilities FROM model_catalog
WHERE model_id = $1 AND provider_config_id = $2
`, modelID, configID).Scan(&capsJSON)
if err != nil {
return nil, err
}
return capsJSON, nil
}
func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) {
var capsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT capabilities FROM model_catalog
WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1
`, modelID).Scan(&capsJSON)
if err != nil {
return nil, err
}
return capsJSON, nil
}
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name AS provider_name
FROM model_catalog mc
JOIN provider_configs ac ON mc.provider_config_id = ac.id
WHERE mc.visibility IN ('enabled', 'team')
AND ac.is_active = true AND ac.scope = 'global'
ORDER BY ac.name, mc.model_id
`)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]store.TeamAvailableModel, 0)
for rows.Next() {
var m store.TeamAvailableModel
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
&m.Provider, &m.ProviderName); err != nil {
continue
}
results = append(results, m)
}
return results, rows.Err()
}

View File

@@ -0,0 +1,57 @@
package postgres
import (
"context"
"database/sql"
)
// ── Mention resolution (v0.29.0) ────────────────────────────────────────
func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) {
var foundModelID, configID string
err := DB.QueryRowContext(ctx, `
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) = LOWER($1)
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY
CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`, modelID).Scan(&foundModelID, &configID)
if err == sql.ErrNoRows {
return "", "", nil
}
return foundModelID, configID, err
}
func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(DISTINCT mc.model_id)
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE LOWER($1)
AND mc.visibility = 'enabled'
AND pc.is_active = true
`, prefix+"%").Scan(&count)
if err != nil {
return "", "", 0, err
}
if count != 1 {
return "", "", count, nil
}
var modelID, configID string
err = DB.QueryRowContext(ctx, `
SELECT mc.model_id, mc.provider_config_id
FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE LOWER(mc.model_id) LIKE LOWER($1)
AND mc.visibility = 'enabled'
AND pc.is_active = true
ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END
LIMIT 1
`, prefix+"%").Scan(&modelID, &configID)
return modelID, configID, 1, err
}

View File

@@ -6,6 +6,9 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
@@ -471,3 +474,492 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error {
_, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = $1`, id)
return err
}
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) {
var channelID string
err := DB.QueryRowContext(ctx, `
SELECT cp1.channel_id FROM channel_participants cp1
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
JOIN channels c ON c.id = cp1.channel_id
WHERE c.type = 'dm'
AND cp1.participant_type = 'user' AND cp1.participant_id = $1
AND cp2.participant_type = 'user' AND cp2.participant_id = $2
LIMIT 1
`, userID1, userID2).Scan(&channelID)
if err == sql.ErrNoRows {
return "", nil
}
return channelID, err
}
func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM messages m
JOIN channel_participants cp ON cp.channel_id = m.channel_id
WHERE cp.channel_id = $1
AND cp.participant_type = 'user' AND cp.participant_id = $2
AND m.created_at > cp.last_read_at
`, channelID, userID).Scan(&count)
return count, err
}
func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) {
result, err := DB.ExecContext(ctx,
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
channelID, userID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
// Update last_read_at
_, err := DB.ExecContext(ctx, `
UPDATE channel_participants
SET last_read_at = NOW()
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`, channelID, userID)
if err != nil {
return nil // participant may not exist for legacy chats
}
// Best-effort: update last_read_message_id
var latestMsgID *string
_ = DB.QueryRowContext(ctx, `
SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1
`, channelID).Scan(&latestMsgID)
if latestMsgID != nil {
_, _ = DB.ExecContext(ctx, `
UPDATE channel_participants
SET last_read_message_id = $1
WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3
`, *latestMsgID, channelID, userID)
}
return nil
}
func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM channel_participants
WHERE channel_id = $1 AND participant_type = $2
`, channelID, pType).Scan(&count)
return count, err
}
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
var count int
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count)
return count, err
}
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
stage_data = $3, workflow_status = $4, last_activity_at = $5
WHERE id = $6
`, workflowID, version, stageData, status, time.Now().UTC(), channelID)
return err
}
func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) {
var ws store.WorkflowChannelStatus
var stageData []byte
err := DB.QueryRowContext(ctx, `
SELECT workflow_id, workflow_version, current_stage,
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
last_activity_at
FROM channels WHERE id = $1 AND type = 'workflow'
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ws.StageData = stageData
return &ws, nil
}
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET current_stage = $1, stage_data = $2, last_activity_at = $3
WHERE id = $4
`, nextStage, stageData, time.Now().UTC(), channelID)
return err
}
func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels
SET current_stage = $1, workflow_status = 'completed',
stage_data = $2, last_activity_at = $3, ai_mode = 'off'
WHERE id = $4
`, finalStage, stageData, time.Now().UTC(), channelID)
return err
}
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
`, stage, time.Now().UTC(), channelID)
return err
}
func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) {
var data json.RawMessage
err := DB.QueryRowContext(ctx, `
SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1
`, channelID).Scan(&data)
return data, err
}
// ── Background job helpers (v0.29.0-cs4) ────────────────────────────────
func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) {
result, err := DB.ExecContext(ctx, `
UPDATE channels
SET workflow_status = 'stale'
WHERE type = 'workflow'
AND workflow_status = 'active'
AND last_activity_at < $1
`, cutoff)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) {
result, err := DB.ExecContext(ctx, `
DELETE FROM channels
WHERE type = 'workflow'
AND workflow_status IN ('completed', 'archived')
AND workflow_id IS NOT NULL
AND workflow_id IN (
SELECT id FROM workflows
WHERE retention IS NOT NULL
AND retention->>'mode' = 'delete'
AND (retention->>'delete_after_days')::int > 0
AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval
)
`)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) {
var chType string
var allowAnon bool
err := DB.QueryRowContext(ctx,
`SELECT type, allow_anonymous FROM channels WHERE id = $1`, channelID).Scan(&chType, &allowAnon)
return chType, allowAnon, err
}
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'),
COUNT(m.id) AS msg_count,
COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars
FROM channels c
JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL
WHERE c.type = 'direct'
AND c.is_archived = false
AND c.updated_at < $1
AND c.updated_at > $2
GROUP BY c.id
HAVING COUNT(m.id) >= $3
AND COALESCE(SUM(LENGTH(m.content)), 0) > $4
ORDER BY c.updated_at DESC
LIMIT $5
`, activityBefore, createdAfter, minMessages, minChars, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
var settingsRaw string
var msgCount, totalChars int
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil {
continue
}
ch.Settings = models.JSONMap{}
_ = json.Unmarshal([]byte(settingsRaw), &ch.Settings)
result = append(result, ch)
}
if result == nil {
result = []models.Channel{}
}
return result, rows.Err()
}
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) {
var configID sql.NullString
err := DB.QueryRowContext(ctx,
`SELECT provider_config_id FROM channels WHERE id = $1`, channelID).Scan(&configID)
if err != nil {
return nil, err
}
return NullableStringPtr(configID), nil
}
func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) {
var ok bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM channels WHERE id = $1 AND user_id = $2
UNION ALL
SELECT 1 FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
LIMIT 1
)`, channelID, userID).Scan(&ok)
return ok, err
}
// ── CS7b additions (v0.29.0) ────────────────────────────────────────────
const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at`
const channelListFrom = `channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id
) mc ON mc.channel_id = c.id`
func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) {
b := NewSelect(channelListCols, channelListFrom)
b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID)
b.Where("c.is_archived = ?", f.Archived)
if len(f.Types) == 1 {
b.Where("c.type = ?", f.Types[0])
} else if len(f.Types) > 1 {
placeholders := make([]string, len(f.Types))
for i, t := range f.Types {
b.argIdx++
placeholders[i] = fmt.Sprintf("$%d", b.argIdx)
b.args = append(b.args, t)
}
b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")")
}
if f.Folder != "" {
b.Where("c.folder = ?", f.Folder)
}
if f.FolderID != "" {
b.Where("c.folder_id = ?", f.FolderID)
}
if f.Search != "" {
b.Where("c.title ILIKE ?", "%"+f.Search+"%")
}
if f.ProjectID == "none" {
b.WhereRaw("c.project_id IS NULL")
} else if f.ProjectID != "" {
b.Where("c.project_id = ?", f.ProjectID)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC")
b.Paginate(f.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanChannelListItems(rows)
if err != nil {
return nil, 0, err
}
// Compute unread counts
for i := range items {
DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM messages m
JOIN channel_participants cp ON cp.channel_id = m.channel_id
WHERE cp.channel_id = $1
AND cp.participant_type = 'user' AND cp.participant_id = $2
AND m.created_at > cp.last_read_at
AND m.deleted_at IS NULL
`, items[i].ID, userID).Scan(&items[i].UnreadCount)
}
return items, total, nil
}
func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) {
row := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT %s FROM %s
WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2
))
`, channelListCols, channelListFrom), channelID, userID)
var item store.ChannelListItem
var tags []byte
var settings []byte
err := row.Scan(
&item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic,
&item.Description, &item.Model, &item.ProviderConfigID,
&item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID,
&tags, &settings,
&item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime,
)
if err != nil {
return nil, err
}
item.Tags = scanTagsBytes(tags)
item.Settings = safeJSONBytes(settings)
item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z")
item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z")
return &item, nil
}
func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string,
ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if tags == nil {
tags = []string{}
}
// Insert channel
err = tx.QueryRowContext(ctx, `
INSERT INTO channels (user_id, title, type, description, model, system_prompt,
provider_config_id, folder, folder_id, tags, ai_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at`,
ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt,
models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID),
pq.Array(tags), aiMode,
).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return fmt.Errorf("CreateFull insert channel: %w", err)
}
// Add owner participant
_, err = tx.ExecContext(ctx, `
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'owner')
ON CONFLICT DO NOTHING`, ch.ID, ownerUserID)
if err != nil {
return fmt.Errorf("CreateFull add owner: %w", err)
}
// Add DM partner participants
for _, pid := range dmPartnerIDs {
if pid == ownerUserID {
continue
}
_, _ = tx.ExecContext(ctx, `
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
VALUES ($1, 'user', $2, 'member')
ON CONFLICT DO NOTHING`, ch.ID, pid)
}
// Auto-create channel_model if model specified
if defaultModel != "" {
_, _ = tx.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING`, ch.ID, defaultModel, models.NullString(&defaultConfigID))
}
return tx.Commit()
}
func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error {
_, err := DB.ExecContext(ctx,
`UPDATE channels SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,
[]byte(settingsJSON), channelID)
return err
}
// ── CS7b helpers ────────────────────────────
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
var result []store.ChannelListItem
for rows.Next() {
var item store.ChannelListItem
var tags []byte
var settings []byte
err := rows.Scan(
&item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic,
&item.Description, &item.Model, &item.ProviderConfigID,
&item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID,
&tags, &settings,
&item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime,
)
if err != nil {
return nil, err
}
item.Tags = scanTagsBytes(tags)
item.Settings = safeJSONBytes(settings)
item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z")
item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z")
result = append(result, item)
}
return result, rows.Err()
}
func scanTagsBytes(b []byte) []string {
if len(b) == 0 {
return []string{}
}
// Try JSON array first (SQLite path), then PG text[] format
var arr []string
if json.Unmarshal(b, &arr) == nil {
if arr == nil {
return []string{}
}
return arr
}
// PG text[] format: {tag1,tag2}
s := strings.TrimPrefix(strings.TrimSuffix(string(b), "}"), "{")
if s == "" {
return []string{}
}
return strings.Split(s, ",")
}
func safeJSONBytes(b []byte) json.RawMessage {
if len(b) == 0 || !json.Valid(b) {
return json.RawMessage("{}")
}
cp := make([]byte, len(b))
copy(cp, b)
return json.RawMessage(cp)
}

View File

@@ -0,0 +1,146 @@
package postgres
import (
"context"
"database/sql"
)
// ── Single-field helpers (v0.29.0) ──────────────────────────────────────
// Moved from handlers/completion.go and handlers/messages.go raw SQL.
func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) {
var aiMode string
err := DB.QueryRowContext(ctx, `
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
`, channelID).Scan(&aiMode)
if err != nil {
return "auto", err
}
return aiMode, nil
}
func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) {
var channelType string
var teamID *string
err := DB.QueryRowContext(ctx, `
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`, channelID).Scan(&channelType, &teamID)
return channelType, teamID, err
}
func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) {
var prompt *string
err := DB.QueryRowContext(ctx, `
SELECT system_prompt FROM channels WHERE id = $1
`, channelID).Scan(&prompt)
if err == sql.ErrNoRows {
return nil, nil
}
return prompt, err
}
func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) {
var model *string
err := DB.QueryRowContext(ctx, `
SELECT model FROM channels WHERE id = $1
`, channelID).Scan(&model)
if err == sql.ErrNoRows {
return nil, nil
}
return model, err
}
func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error {
_, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
return err
}
func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
`, channelID, excludeUserID)
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)
}
if ids == nil {
ids = []string{}
}
return ids, rows.Err()
}
func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'persona'
ORDER BY created_at
`, channelID)
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)
}
if ids == nil {
ids = []string{}
}
return ids, rows.Err()
}
func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) {
// Try group leader first
var leaderID string
err := DB.QueryRowContext(ctx, `
SELECT cp.participant_id
FROM channel_participants cp
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
WHERE cp.channel_id = $1
AND cp.participant_type = 'persona'
AND pgm.is_leader = true
LIMIT 1
`, channelID).Scan(&leaderID)
if err == nil && leaderID != "" {
return leaderID, nil
}
// Fallback: first persona participant
err = DB.QueryRowContext(ctx, `
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'persona'
ORDER BY created_at LIMIT 1
`, channelID).Scan(&leaderID)
if err == sql.ErrNoRows {
return "", nil
}
return leaderID, err
}
func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) {
var workflowID *string
var currentStage int
err := DB.QueryRowContext(ctx, `
SELECT workflow_id, COALESCE(current_stage, 0)
FROM channels WHERE id = $1 AND type = 'workflow'
`, channelID).Scan(&workflowID, &currentStage)
if err == sql.ErrNoRows {
return nil, 0, nil
}
return workflowID, currentStage, err
}

View File

@@ -0,0 +1,169 @@
package postgres
import (
"context"
"database/sql"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ExtensionPermissionStore struct {
db *sql.DB
}
func NewExtensionPermissionStore(db *sql.DB) *ExtensionPermissionStore {
return &ExtensionPermissionStore{db: db}
}
func (s *ExtensionPermissionStore) DeclareForPackage(ctx context.Context, packageID string, permissions []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Remove permissions no longer in manifest
if len(permissions) == 0 {
_, err = tx.ExecContext(ctx, `DELETE FROM extension_permissions WHERE package_id = $1`, packageID)
if err != nil {
return err
}
return tx.Commit()
}
// Build set of current declared
declared := make(map[string]bool, len(permissions))
for _, p := range permissions {
declared[p] = true
}
// Get existing
rows, err := tx.QueryContext(ctx,
`SELECT permission FROM extension_permissions WHERE package_id = $1`, packageID)
if err != nil {
return err
}
existing := make(map[string]bool)
for rows.Next() {
var perm string
if err := rows.Scan(&perm); err != nil {
rows.Close()
return err
}
existing[perm] = true
}
rows.Close()
// Delete removed
for perm := range existing {
if !declared[perm] {
_, err = tx.ExecContext(ctx,
`DELETE FROM extension_permissions WHERE package_id = $1 AND permission = $2`,
packageID, perm)
if err != nil {
return err
}
}
}
// Upsert new (preserving existing grants)
for _, perm := range permissions {
if !existing[perm] {
_, err = tx.ExecContext(ctx,
`INSERT INTO extension_permissions (id, package_id, permission)
VALUES (gen_random_uuid(), $1, $2)
ON CONFLICT (package_id, permission) DO NOTHING`,
packageID, perm)
if err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *ExtensionPermissionStore) ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, package_id, permission, granted, granted_by, granted_at, created_at
FROM extension_permissions
WHERE package_id = $1
ORDER BY permission`, packageID)
if err != nil {
return nil, err
}
defer rows.Close()
var perms []models.ExtensionPermission
for rows.Next() {
var p models.ExtensionPermission
if err := rows.Scan(&p.ID, &p.PackageID, &p.Permission, &p.Granted, &p.GrantedBy, &p.GrantedAt, &p.CreatedAt); err != nil {
return nil, err
}
perms = append(perms, p)
}
if perms == nil {
perms = []models.ExtensionPermission{}
}
return perms, nil
}
func (s *ExtensionPermissionStore) GrantedForPackage(ctx context.Context, packageID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT permission FROM extension_permissions
WHERE package_id = $1 AND granted = true
ORDER BY permission`, packageID)
if err != nil {
return nil, err
}
defer rows.Close()
var perms []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
perms = append(perms, p)
}
if perms == nil {
perms = []string{}
}
return perms, nil
}
func (s *ExtensionPermissionStore) Grant(ctx context.Context, packageID, permission, grantedBy string) error {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`UPDATE extension_permissions
SET granted = true, granted_by = $1, granted_at = $2
WHERE package_id = $3 AND permission = $4`,
grantedBy, now, packageID, permission)
return err
}
func (s *ExtensionPermissionStore) Revoke(ctx context.Context, packageID, permission string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE extension_permissions
SET granted = false, granted_by = NULL, granted_at = NULL
WHERE package_id = $1 AND permission = $2`,
packageID, permission)
return err
}
func (s *ExtensionPermissionStore) GrantAll(ctx context.Context, packageID, grantedBy string) error {
now := time.Now()
_, err := s.db.ExecContext(ctx,
`UPDATE extension_permissions
SET granted = true, granted_by = $1, granted_at = $2
WHERE package_id = $3 AND granted = false`,
grantedBy, now, packageID)
return err
}
func (s *ExtensionPermissionStore) DeleteForPackage(ctx context.Context, packageID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM extension_permissions WHERE package_id = $1`, packageID)
return err
}

View File

@@ -249,3 +249,12 @@ func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([
}
return out, rows.Err()
}
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error {
_, err := DB.ExecContext(ctx,
`UPDATE files SET storage_key = $1, updated_at = NOW() WHERE id = $2`,
key, id)
return err
}

View File

@@ -0,0 +1,75 @@
package postgres
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type FolderStore struct{}
func NewFolderStore() *FolderStore { return &FolderStore{} }
func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, parent_id, sort_order, created_at, updated_at
FROM folders WHERE user_id = $1
ORDER BY sort_order, name
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Folder
for rows.Next() {
var f models.Folder
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder,
&f.CreatedAt, &f.UpdatedAt); err != nil {
continue
}
f.UserID = userID
result = append(result, f)
}
if result == nil {
result = []models.Folder{}
}
return result, rows.Err()
}
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
return DB.QueryRowContext(ctx, `
INSERT INTO folders (user_id, name, sort_order)
VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order, created_at, updated_at
`, f.UserID, f.Name, f.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
}
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order)
WHERE id = $1 AND user_id = $2
`, folderID, userID, name, sortOrder)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM folders WHERE id = $1 AND user_id = $2`, folderID, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2`, folderID, userID)
return err
}

View File

@@ -57,3 +57,40 @@ func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONM
}
return result, rows.Err()
}
// ── OIDC state (v0.29.0-cs4) ────────────────────────────────────────────
func (s *GlobalConfigStore) SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3)
`, state, nonce, redirectTo)
return err
}
func (s *GlobalConfigStore) ConsumeOIDCState(ctx context.Context, state string) (string, string, error) {
var nonce, redirectTo string
err := DB.QueryRowContext(ctx, `
SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1
`, state).Scan(&nonce, &redirectTo)
if err != nil {
return "", "", err
}
// Delete (one-time use)
_, _ = DB.ExecContext(ctx, `DELETE FROM oidc_auth_state WHERE state = $1`, state)
return nonce, redirectTo, nil
}
func (s *GlobalConfigStore) CleanupOIDCState(ctx context.Context) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`)
return err
}
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
func (s *GlobalConfigStore) GetString(ctx context.Context, key string) (string, error) {
var val string
err := DB.QueryRowContext(ctx,
"SELECT value FROM global_settings WHERE key = $1", key).Scan(&val)
return val, err
}

View File

@@ -270,3 +270,34 @@ func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
// ensure compile-time interface satisfaction
var _ store.MemoryStore = (*MemoryStore)(nil)
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error {
_, err := DB.ExecContext(ctx,
`UPDATE memories SET embedding = $1::vector WHERE id = $2`, embedding, id)
return err
}
func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) {
var lastID string
err := DB.QueryRowContext(ctx,
`SELECT last_message_id FROM memory_extraction_log WHERE channel_id = $1 AND user_id = $2`,
channelID, userID).Scan(&lastID)
if err != nil {
return "", nil // no entry yet
}
return lastID, nil
}
func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO memory_extraction_log (channel_id, user_id, last_message_id, memory_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT(channel_id, user_id) DO UPDATE SET
last_message_id = EXCLUDED.last_message_id,
extracted_at = now(),
memory_count = memory_extraction_log.memory_count + EXCLUDED.memory_count
`, channelID, userID, lastMessageID, count)
return err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -136,7 +137,9 @@ func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]m
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
FROM messages m JOIN path p ON m.id = p.parent_id
)
SELECT * FROM path ORDER BY created_at ASC`, messageID)
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM path ORDER BY created_at ASC`, messageID)
if err != nil {
return nil, err
}
@@ -189,3 +192,115 @@ func scanMessages(rows *sql.Rows) ([]models.Message, error) {
}
return result, rows.Err()
}
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *MessageStore) CountAll(ctx context.Context) (int, error) {
var count int
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count)
return count, err
}
// ── CS5c additions (v0.29.0) ─────────────────────────────────────────────
func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) {
roleClause := ""
queryArgs := []interface{}{channelID, query, limit}
if roleFilter == "user" || roleFilter == "assistant" {
roleClause = "AND m.role = $4"
queryArgs = append(queryArgs, roleFilter)
}
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT m.id, m.role,
ts_headline('english', m.content, plainto_tsquery('english', $2),
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline,
ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank,
m.created_at
FROM messages m
WHERE m.channel_id = $1
AND m.deleted_at IS NULL
AND m.role IN ('user', 'assistant')
AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2)
%s
ORDER BY rank DESC, m.created_at DESC
LIMIT $3
`, roleClause), queryArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]store.ChannelSearchResult, 0)
for rows.Next() {
var r store.ChannelSearchResult
if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil {
continue
}
results = append(results, r)
}
return results, rows.Err()
}
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) {
var total int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
channelID).Scan(&total)
if err != nil {
return nil, 0, err
}
rows, err := DB.QueryContext(ctx, `
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
WHEN m.participant_type = 'persona' THEN p.avatar
ELSE NULL END AS sender_avatar,
m.created_at
FROM messages m
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
ORDER BY m.created_at ASC
LIMIT $2 OFFSET $3
`, channelID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
results := make([]store.MessageWithSender, 0)
for rows.Next() {
var m store.MessageWithSender
if err := rows.Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content,
&m.Model, &m.TokensUsed, &m.ParentID,
&m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
&m.SenderName, &m.SenderAvatar,
&m.CreatedAt,
); err != nil {
return nil, total, err
}
results = append(results, m)
}
return results, total, rows.Err()
}
func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) {
var parentID sql.NullString
var role string
err := DB.QueryRowContext(ctx, `
SELECT parent_id, role FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
`, messageID, channelID).Scan(&parentID, &role)
if err != nil {
return nil, "", err
}
return NullableStringPtr(parentID), role, nil
}

View File

@@ -0,0 +1,383 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Tree Operations (v0.29.0) ───────────────────────────────────────────
// Moved from treepath package. All message tree traversal goes through
// the store interface now.
func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) {
var leafID *string
// Try cursor first
err := DB.QueryRowContext(ctx, `
SELECT active_leaf_id FROM channel_cursors
WHERE channel_id = $1 AND user_id = $2
`, channelID, userID).Scan(&leafID)
if err == nil && leafID != nil {
// Verify the leaf still exists and isn't deleted
var exists bool
DB.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL)
`, *leafID).Scan(&exists)
if exists {
return leafID, nil
}
}
// Fallback: latest live message in channel
var fallbackID string
err = DB.QueryRowContext(ctx, `
SELECT id FROM messages
WHERE channel_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1
`, channelID).Scan(&fallbackID)
if err == sql.ErrNoRows {
return nil, nil // empty channel
}
if err != nil {
return nil, err
}
return &fallbackID, nil
}
func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) {
rows, err := DB.QueryContext(ctx, `
WITH RECURSIVE path AS (
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
participant_type, participant_id, sibling_index, created_at,
0 AS depth
FROM messages
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
UNION ALL
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
p.depth + 1
FROM messages m
JOIN path p ON m.id = p.parent_id
WHERE m.deleted_at IS NULL
)
SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata,
participant_type, participant_id, sibling_index, created_at
FROM path
ORDER BY depth DESC
`, leafID, channelID)
if err != nil {
return nil, fmt.Errorf("GetPathToLeaf: %w", err)
}
defer rows.Close()
var path []store.PathMessage
for rows.Next() {
var m store.PathMessage
var participantType, participantID sql.NullString
var toolCallsJSON, metadataJSON []byte
if err := rows.Scan(
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("GetPathToLeaf scan: %w", err)
}
if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" {
raw := json.RawMessage(toolCallsJSON)
m.ToolCalls = &raw
}
if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" {
raw := json.RawMessage(metadataJSON)
m.Metadata = &raw
}
if participantType.Valid {
m.ParticipantType = participantType.String
}
if participantID.Valid {
m.ParticipantID = participantID.String
}
path = append(path, m)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Enrich with sibling counts
for i := range path {
count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID)
path[i].SiblingCount = count
}
// Resolve sender info
_ = s.ResolveSenderInfo(ctx, path)
return path, nil
}
func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) {
leafID, err := s.GetActiveLeaf(ctx, channelID, userID)
if err != nil {
return nil, err
}
if leafID == nil {
return []store.PathMessage{}, nil
}
return s.GetPathToLeaf(ctx, channelID, *leafID)
}
func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) {
// Get parent_id and channel_id of the target message
var parentID *string
var channelID string
err := DB.QueryRowContext(ctx, `
SELECT parent_id, channel_id FROM messages
WHERE id = $1 AND deleted_at IS NULL
`, messageID).Scan(&parentID, &channelID)
if err != nil {
return nil, 0, fmt.Errorf("message not found: %w", err)
}
var rows *sql.Rows
if parentID == nil {
rows, err = DB.QueryContext(ctx, `
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
ORDER BY sibling_index, created_at
`, channelID)
} else {
rows, err = DB.QueryContext(ctx, `
SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at
FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
ORDER BY sibling_index, created_at
`, *parentID)
}
if err != nil {
return nil, 0, err
}
defer rows.Close()
var siblings []store.SiblingInfo
currentIdx := 0
for i := 0; rows.Next(); i++ {
var si store.SiblingInfo
if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil {
return nil, 0, err
}
if si.ID == messageID {
currentIdx = i
}
siblings = append(siblings, si)
}
return siblings, currentIdx, rows.Err()
}
func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) {
var count int
var err error
if parentID == nil {
err = DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
`, channelID).Scan(&count)
} else {
err = DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
`, *parentID).Scan(&count)
}
if err != nil || count == 0 {
return 1, nil // minimum 1 (the message itself)
}
return count, nil
}
func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) {
var leafID string
err := DB.QueryRowContext(ctx, `
WITH RECURSIVE descendants AS (
SELECT id, 0 AS depth
FROM messages
WHERE id = $1 AND deleted_at IS NULL
UNION ALL
SELECT child.id, d.depth + 1
FROM messages child
JOIN descendants d ON child.parent_id = d.id
WHERE child.deleted_at IS NULL
AND child.sibling_index = (
SELECT MIN(sibling_index) FROM messages
WHERE parent_id = d.id AND deleted_at IS NULL
)
)
SELECT id FROM descendants
ORDER BY depth DESC
LIMIT 1
`, messageID).Scan(&leafID)
if err != nil {
return messageID, nil // fallback to message itself
}
return leafID, nil
}
func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) {
var maxIdx sql.NullInt64
var err error
if parentID == nil {
err = DB.QueryRowContext(ctx, `
SELECT MAX(sibling_index) FROM messages
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
`, channelID).Scan(&maxIdx)
} else {
err = DB.QueryRowContext(ctx, `
SELECT MAX(sibling_index) FROM messages
WHERE parent_id = $1 AND deleted_at IS NULL
`, *parentID).Scan(&maxIdx)
}
if err != nil || !maxIdx.Valid {
return 0, nil
}
return int(maxIdx.Int64) + 1, nil
}
func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) {
var id string
err := DB.QueryRowContext(ctx, `
SELECT id FROM messages
WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona'
LIMIT 1
`, channelID).Scan(&id)
return err == nil && id != "", nil
}
func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error {
// Insert message — PG generates ID via gen_random_uuid()
err := DB.QueryRowContext(ctx, `
INSERT INTO messages (channel_id, role, content, model, tokens_used,
tool_calls, parent_id, participant_type, participant_id,
provider_config_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at`,
m.ChannelID, m.Role, m.Content, safeModel(m.Model), m.TokensUsed,
ToJSON(m.ToolCalls), models.NullString(m.ParentID),
m.ParticipantType, m.ParticipantID,
safeString(m.ProviderConfigID), m.SiblingIndex,
).Scan(&m.ID, &m.CreatedAt)
if err != nil {
return fmt.Errorf("CreateWithCursor insert: %w", err)
}
// Update cursor
if cursorUserID != "" {
_, _ = DB.ExecContext(ctx, `
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()
`, m.ChannelID, cursorUserID, m.ID)
}
// Touch channel
_, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, m.ChannelID)
return nil
}
func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error {
// Collect unique participant IDs by type
userIDs := map[string]bool{}
personaIDs := map[string]bool{}
for _, m := range path {
if m.ParticipantID == "" {
continue
}
switch m.ParticipantType {
case "user":
userIDs[m.ParticipantID] = true
case "persona":
personaIDs[m.ParticipantID] = true
}
}
// Resolve users
userNames := map[string]string{}
userAvatars := map[string]string{}
for uid := range userIDs {
var name, avatar sql.NullString
_ = DB.QueryRowContext(ctx, `
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
`, uid).Scan(&name, &avatar)
if name.Valid {
userNames[uid] = name.String
}
if avatar.Valid {
userAvatars[uid] = avatar.String
}
}
// Resolve personas
personaNames := map[string]string{}
personaAvatars := map[string]string{}
for pid := range personaIDs {
var name, avatar sql.NullString
_ = DB.QueryRowContext(ctx, `
SELECT name, avatar FROM personas WHERE id = $1
`, pid).Scan(&name, &avatar)
if name.Valid {
personaNames[pid] = name.String
}
if avatar.Valid {
personaAvatars[pid] = avatar.String
}
}
// Apply to path
for i := range path {
pid := path[i].ParticipantID
switch path[i].ParticipantType {
case "user":
if n, ok := userNames[pid]; ok {
path[i].SenderName = &n
}
if a, ok := userAvatars[pid]; ok && a != "" {
path[i].SenderAvatar = &a
}
case "persona":
if n, ok := personaNames[pid]; ok {
path[i].SenderName = &n
}
if a, ok := personaAvatars[pid]; ok && a != "" {
path[i].SenderAvatar = &a
}
}
}
return nil
}
// ── helpers ─────────────────────────────────
func safeModel(m string) interface{} {
if m == "" {
return ""
}
return m
}
func safeString(s *string) interface{} {
if s == nil || *s == "" {
return nil
}
return *s
}

View File

@@ -219,3 +219,100 @@ func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limi
}
return results, rows.Err()
}
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error {
_, err := DB.ExecContext(ctx,
`UPDATE notes SET embedding = $1::vector WHERE id = $2`,
vecStr, noteID)
return err
}
func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, title, folder_path, tags, LEFT(content, 500),
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
ts_headline('english', content, plainto_tsquery('english', $2),
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
FROM notes
WHERE user_id = $1
AND search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC
LIMIT $3
`, userID, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]store.NoteSearchResult, 0)
for rows.Next() {
var r store.NoteSearchResult
var dbTags pq.StringArray
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
continue
}
r.Tags = []string(dbTags)
if r.Tags == nil {
r.Tags = []string{}
}
results = append(results, r)
}
return results, rows.Err()
}
func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, title, folder_path, tags, LEFT(content, 500),
1 - (embedding <=> $2::vector) AS similarity
FROM notes
WHERE user_id = $1
AND embedding IS NOT NULL
AND 1 - (embedding <=> $2::vector) > 0.3
ORDER BY embedding <=> $2::vector
LIMIT $3
`, userID, vecStr, limit)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]store.NoteSearchResult, 0)
for rows.Next() {
var r store.NoteSearchResult
var dbTags pq.StringArray
if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank); err != nil {
continue
}
r.Tags = []string(dbTags)
if r.Tags == nil {
r.Tags = []string{}
}
results = append(results, r)
}
return results, rows.Err()
}
func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) {
rows, err := DB.QueryContext(ctx, `
SELECT DISTINCT folder_path, COUNT(*) AS count
FROM notes WHERE user_id = $1
GROUP BY folder_path
ORDER BY folder_path
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]store.FolderInfo, 0)
for rows.Next() {
var f store.FolderInfo
if err := rows.Scan(&f.Path, &f.Count); err != nil {
continue
}
results = append(results, f)
}
return results, rows.Err()
}

View File

@@ -51,6 +51,20 @@ func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool)
return nil
}
func (s *PackageStore) SetStatus(ctx context.Context, id string, status string) error {
result, err := DB.ExecContext(ctx,
`UPDATE packages SET status = $2, updated_at = NOW() WHERE id = $1`,
id, status)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) Delete(ctx context.Context, id string) error {
result, err := DB.ExecContext(ctx,
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
@@ -87,15 +101,18 @@ func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
if pkg.Status == "" {
pkg.Status = "active"
}
return DB.QueryRowContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
is_system, scope, team_id, installed_by, manifest, enabled, status, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING installed_at, updated_at`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
manifestJSON, pkg.Enabled, pkg.Source,
manifestJSON, pkg.Enabled, pkg.Status, pkg.Source,
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
}
@@ -155,7 +172,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
&teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Source,
&manifestJSON, &up.Enabled, &up.Status, &up.Source,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
@@ -218,7 +235,7 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str
// so column additions don't silently break positional Scan().
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
p.manifest, p.enabled, p.source, p.installed_at, p.updated_at`
p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at`
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration
@@ -228,7 +245,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Source,
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
@@ -259,7 +276,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Source,
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err

View File

@@ -0,0 +1,138 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PersonaGroupStore struct{}
func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} }
const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at`
func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) {
rows, err := DB.QueryContext(ctx, `
SELECT `+personaGroupCols+` FROM persona_groups
WHERE owner_id = $1 ORDER BY name
`, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
groups := []models.PersonaGroup{}
for rows.Next() {
var g models.PersonaGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil {
continue
}
groups = append(groups, g)
}
return groups, rows.Err()
}
func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) {
var g models.PersonaGroup
err := DB.QueryRowContext(ctx, `
SELECT `+personaGroupCols+` FROM persona_groups WHERE id = $1 AND owner_id = $2
`, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &g, nil
}
func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error {
return DB.QueryRowContext(ctx, `
INSERT INTO persona_groups (name, description, owner_id, scope)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
`, g.Name, g.Description, g.OwnerID, g.Scope).Scan(
&g.ID, &g.Name, &g.Description, &g.OwnerID,
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
}
func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
for k, v := range fields {
_, err := DB.ExecContext(ctx,
`UPDATE persona_groups SET `+k+` = $1, updated_at = NOW() WHERE id = $2`, v, id)
if err != nil {
return err
}
}
return nil
}
func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) {
result, err := DB.ExecContext(ctx,
`DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2`, id, ownerID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) {
var ownerID string
err := DB.QueryRowContext(ctx,
`SELECT owner_id FROM persona_groups WHERE id = $1`, id).Scan(&ownerID)
if err == sql.ErrNoRows {
return "", nil
}
return ownerID, err
}
func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error {
if isLeader {
_, _ = DB.ExecContext(ctx,
`UPDATE persona_group_members SET is_leader = false WHERE group_id = $1`, groupID)
}
_, err := DB.ExecContext(ctx, `
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
VALUES ($1, $2, $3)
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
`, groupID, personaID, isLeader)
return err
}
func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error {
_, err := DB.ExecContext(ctx,
`DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2`, memberID, groupID)
return err
}
func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
COALESCE(p.name, '') AS persona_name,
COALESCE(p.handle, '') AS persona_handle,
COALESCE(p.avatar, '') AS persona_avatar
FROM persona_group_members pgm
LEFT JOIN personas p ON p.id = pgm.persona_id
WHERE pgm.group_id = $1
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
`, groupID)
if err != nil {
return []models.PersonaGroupMember{}, nil
}
defer rows.Close()
members := []models.PersonaGroupMember{}
for rows.Next() {
var m models.PersonaGroupMember
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
continue
}
members = append(members, m)
}
return members, rows.Err()
}

View File

@@ -0,0 +1,80 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────
func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) {
var id string
err := DB.QueryRowContext(ctx, `
SELECT id FROM personas
WHERE LOWER(handle) = LOWER($1) AND is_active = true
LIMIT 1
`, handle).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
return id, err
}
func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true
`, prefix+"%").Scan(&count)
if err != nil {
return "", 0, err
}
if count != 1 {
return "", count, nil
}
var id string
err = DB.QueryRowContext(ctx, `
SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true
`, prefix+"%").Scan(&id)
return id, 1, err
}
func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) {
var name string
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name)
if err == sql.ErrNoRows {
return "", nil
}
return name, err
}
func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) {
result := make(map[string]string)
for _, id := range ids {
var name string
err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name)
if err == nil && name != "" {
result[id] = name
}
}
return result, nil
}
func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
result := make(map[string]store.UserDisplayInfo)
for _, id := range ids {
var name, avatar sql.NullString
_ = DB.QueryRowContext(ctx, `
SELECT name, avatar FROM personas WHERE id = $1
`, id).Scan(&name, &avatar)
if name.Valid {
info := store.UserDisplayInfo{Name: name.String}
if avatar.Valid {
info.Avatar = avatar.String
}
result[id] = info
}
}
return result, nil
}

View File

@@ -0,0 +1,50 @@
package postgres
import (
"context"
"database/sql"
"time"
)
// PresenceStore manages user_presence table.
type PresenceStore struct{}
func NewPresenceStore() *PresenceStore { return &PresenceStore{} }
func (s *PresenceStore) Heartbeat(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO user_presence (user_id, last_seen, status)
VALUES ($1, NOW(), 'online')
ON CONFLICT (user_id) DO UPDATE
SET last_seen = NOW(), status = 'online'
`, userID)
return err
}
func (s *PresenceStore) GetLastSeen(ctx context.Context, userID string) (*time.Time, error) {
var lastSeen time.Time
err := DB.QueryRowContext(ctx,
`SELECT last_seen FROM user_presence WHERE user_id = $1`, userID).Scan(&lastSeen)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastSeen, nil
}
func (s *PresenceStore) GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) {
result := make(map[string]string, len(userIDs))
for _, id := range userIDs {
var lastSeen time.Time
err := DB.QueryRowContext(ctx,
`SELECT last_seen FROM user_presence WHERE user_id = $1`, id).Scan(&lastSeen)
if err != nil || lastSeen.Before(threshold) {
result[id] = "offline"
} else {
result[id] = "online"
}
}
return result, nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── ProjectStore ───────────────────────────
@@ -395,6 +396,45 @@ func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID str
return projectID, err
}
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) {
rows, err := DB.QueryContext(ctx, `
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 OR p.is_archived = false)
ORDER BY p.updated_at DESC`, includeArchived)
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
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Scope,
&p.OwnerID, &teamID, &p.IsArchived,
&p.CreatedAt, &p.UpdatedAt,
&p.ChannelCount, &p.KBCount, &p.NoteCount,
&p.OwnerName,
); err != nil {
return nil, err
}
p.TeamID = NullableStringPtr(teamID)
results = append(results, p)
}
return results, rows.Err()
}
// ── Query Helper ────────────────────────────
func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) {

View File

@@ -195,3 +195,64 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
}
return result, rows.Err()
}
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) {
result, err := DB.ExecContext(ctx,
`DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1`, ownerID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// ── CS6 additions (v0.29.0) ─────────────────────────────────────────────
func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = $1 ORDER BY name", providerCols),
teamID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`,
id, teamID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ── CS7a additions (v0.29.0) ────────────────────────────────────────────
func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) {
var configID string
err := DB.QueryRowContext(ctx, `
SELECT id FROM provider_configs
WHERE is_active = true AND (
(scope = 'personal' AND owner_id = $1)
OR scope = 'global'
)
ORDER BY scope ASC, created_at ASC
LIMIT 1
`, userID).Scan(&configID)
return configID, err
}
func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) {
row := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT %s FROM provider_configs
WHERE id = $1 AND is_active = true
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`, providerCols), configID, userID)
return scanProvider(row)
}

View File

@@ -42,5 +42,10 @@ func NewStores(db *sql.DB) store.Stores {
Packages: NewPackageStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
Presence: NewPresenceStore(),
PersonaGroups: NewPersonaGroupStore(),
Folders: NewFolderStore(),
Health: NewHealthStore(db),
ExtPermissions: NewExtensionPermissionStore(db),
}
}

View File

@@ -226,3 +226,101 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
// unused but keeping for reference
var _ = fmt.Sprintf
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists)
return exists, err
}
func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) {
res, err := DB.ExecContext(ctx,
`UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3`,
role, memberID, teamID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM team_members WHERE id = $1 AND team_id = $2`,
memberID, teamID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT DISTINCT al.action
FROM audit_log al
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
ORDER BY al.action
`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var actions []string
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
actions = append(actions, a)
}
if actions == nil {
actions = []string{}
}
return actions, rows.Err()
}
// ── CS5b additions (v0.29.0) ────────────────────────────────────────────
func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) {
var teamID string
err := DB.QueryRowContext(ctx,
`SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1`, userID).Scan(&teamID)
if err != nil {
return "", nil
}
return teamID, nil
}
// ── CS6 additions (v0.29.0) ────────────────────────────────────────────
func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) {
var id string
err := DB.QueryRowContext(ctx, `
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING id`, teamID, userID, role).Scan(&id)
return id, err
}
func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) {
var has bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM team_members tm
JOIN teams t ON t.id = tm.team_id
WHERE tm.user_id = $1
AND t.is_active = true
AND t.settings->>'require_private_providers' = 'true'
)`, userID).Scan(&has)
return has, err
}
func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error {
_, err := DB.ExecContext(ctx,
`UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`,
settingsJSON, teamID)
return err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -186,3 +187,110 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model
ScanJSON(sj, &u.Settings)
return &u, nil
}
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
return exists, err
}
func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) {
q := `
SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle
FROM users
WHERE is_active = true AND id != $1`
args := []interface{}{excludeUserID}
if query != "" {
q += ` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`
pattern := "%" + strings.ToLower(query) + "%"
args = append(args, pattern, pattern, pattern)
}
q += ` ORDER BY username LIMIT 20`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count)
return count, err
}
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
_, err := DB.ExecContext(ctx, `
UPDATE users SET settings = (
CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object'
THEN '{}'::jsonb ELSE settings END
) || $1::jsonb, updated_at = NOW() WHERE id = $2
`, string(patch), userID)
return err
}
func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) {
var vaultSet bool
var encUEK, salt, nonce []byte
err := DB.QueryRowContext(ctx, `
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
return vaultSet, encUEK, salt, nonce, err
}
func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
_, err := DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
WHERE id = $4
`, encUEK, salt, nonce, userID)
return err
}
func (s *UserStore) CountAll(ctx context.Context) (int, error) {
var count int
err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
return count, err
}
// ── CS4 additions (v0.29.0) ─────────────────────────────────────────────
func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`, userID)
return err
}
func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error {
_, err := DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
WHERE id = $4
`, encUEK, salt, nonce, userID)
return err
}

View File

@@ -0,0 +1,62 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Mention resolution + display info (v0.29.0) ────────────────────────
func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) {
var id string
err := DB.QueryRowContext(ctx, `
SELECT id FROM users
WHERE LOWER(handle) = LOWER($1) AND id != $2 AND is_active = true
LIMIT 1
`, handle, excludeUserID).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
return id, err
}
func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM users
WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true
`, prefix+"%", excludeUserID).Scan(&count)
if err != nil {
return "", 0, err
}
if count != 1 {
return "", count, nil
}
var id string
err = DB.QueryRowContext(ctx, `
SELECT id FROM users
WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true
LIMIT 1
`, prefix+"%", excludeUserID).Scan(&id)
return id, 1, err
}
func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) {
result := make(map[string]store.UserDisplayInfo)
for _, id := range ids {
var name, avatar sql.NullString
_ = DB.QueryRowContext(ctx, `
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
`, id).Scan(&name, &avatar)
if name.Valid {
info := store.UserDisplayInfo{Name: name.String}
if avatar.Valid {
info.Avatar = avatar.String
}
result[id] = info
}
}
return result, nil
}

View File

@@ -2,10 +2,13 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// WorkflowStore implements store.WorkflowStore for Postgres.
@@ -357,3 +360,129 @@ func nullIfEmpty(s string) interface{} {
}
return s
}
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error {
a.ID = store.NewID()
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
VALUES ($1, $2, $3, $4)
`, a.ID, a.ChannelID, a.Stage, a.TeamID)
return err
}
func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, stage, team_id, assigned_to, status,
created_at, claimed_at, completed_at
FROM workflow_assignments
WHERE team_id = $1 AND status = $2
ORDER BY created_at DESC
`, teamID, status)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAssignments(rows)
}
func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, `
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
wa.created_at, wa.claimed_at, wa.completed_at
FROM workflow_assignments wa
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
ORDER BY wa.created_at DESC
`, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAssignments(rows)
}
func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`, userID, time.Now().UTC(), assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) {
res, err := DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET status = 'completed', completed_at = $1
WHERE id = $2 AND status = 'claimed'
`, time.Now().UTC(), assignmentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) {
var channelID string
err := DB.QueryRowContext(ctx,
`SELECT channel_id FROM workflow_assignments WHERE id = $1`, assignmentID).Scan(&channelID)
return channelID, err
}
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
// Find least-recently-assigned team member
rows, err := DB.QueryContext(ctx, `
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
FROM team_members m
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
WHERE m.team_id = $2
GROUP BY m.user_id
ORDER BY last_claim ASC
LIMIT 1
`, teamID, teamID)
if err != nil {
return "", err
}
defer rows.Close()
if !rows.Next() {
return "", nil // no team members
}
var userID, lastClaim string
if err := rows.Scan(&userID, &lastClaim); err != nil {
return "", err
}
// Claim for that user
_, err = DB.ExecContext(ctx, `
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`, userID, time.Now().UTC(), assignmentID)
if err != nil {
return "", err
}
return userID, nil
}
func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
var result []store.WorkflowAssignment
for rows.Next() {
var a store.WorkflowAssignment
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
return nil, err
}
result = append(result, a)
}
if result == nil {
result = []store.WorkflowAssignment{}
}
return result, rows.Err()
}