Changeset 0.29.0 (#195)
This commit is contained in:
@@ -477,3 +477,427 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error {
|
||||
_, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, 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 = ?
|
||||
AND cp2.participant_type = 'user' AND cp2.participant_id = ?
|
||||
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 = ?
|
||||
AND cp.participant_type = 'user' AND cp.participant_id = ?
|
||||
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 = ? AND user_id = ?`,
|
||||
channelID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channel_participants
|
||||
SET last_read_at = datetime('now')
|
||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
||||
`, channelID, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var latestMsgID *string
|
||||
_ = DB.QueryRowContext(ctx, `
|
||||
SELECT id FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT 1
|
||||
`, channelID).Scan(&latestMsgID)
|
||||
if latestMsgID != nil {
|
||||
_, _ = DB.ExecContext(ctx, `
|
||||
UPDATE channel_participants
|
||||
SET last_read_message_id = ?
|
||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
||||
`, *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 = ? AND participant_type = ?
|
||||
`, 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 = ?, workflow_version = ?, current_stage = 0,
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?
|
||||
WHERE id = ?
|
||||
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), 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 = ? 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 = ?, stage_data = ?, last_activity_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), 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 = ?, workflow_status = 'completed',
|
||||
stage_data = ?, last_activity_at = ?, ai_mode = 'off'
|
||||
WHERE id = ?
|
||||
`, finalStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
|
||||
`, stage, time.Now().UTC().Format(timeFmt), 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 = ?
|
||||
`, 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 < ?
|
||||
`, cutoff.Format(timeFmt))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) {
|
||||
// SQLite doesn't support JSON operators for retention policy evaluation.
|
||||
// This is a no-op on SQLite; retention enforcement is PG-only.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
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 = ?`, 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) {
|
||||
// Auto-compaction scanner is PG-only (uses settings::text cast).
|
||||
return []models.Channel{}, nil
|
||||
}
|
||||
|
||||
// ── 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 = ?`, 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 = ? AND user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM channel_participants
|
||||
WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ?
|
||||
LIMIT 1
|
||||
)`, channelID, userID, 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)
|
||||
archivedVal := 0
|
||||
if f.Archived {
|
||||
archivedVal = 1
|
||||
}
|
||||
b.Where("c.is_archived = ?", archivedVal)
|
||||
|
||||
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 {
|
||||
placeholders[i] = "?"
|
||||
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 LIKE ?", "%"+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 = ?
|
||||
AND cp.participant_type = 'user' AND cp.participant_id = ?
|
||||
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 = ? AND (c.user_id = ? OR c.id IN (
|
||||
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?
|
||||
))
|
||||
`, channelListCols, channelListFrom), channelID, userID, userID)
|
||||
|
||||
var item store.ChannelListItem
|
||||
var tags, settings string
|
||||
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, st(&item.CreatedAtTime), st(&item.UpdatedAtTime),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Tags = ScanArray(tags)
|
||||
if item.Tags == nil {
|
||||
item.Tags = []string{}
|
||||
}
|
||||
item.Settings = safeJSONString(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 {
|
||||
ch.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
ch.CreatedAt = now
|
||||
ch.UpdatedAt = now
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tagsJSON := ArrayToJSON(tags)
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO channels (id, user_id, title, type, description, model, system_prompt,
|
||||
provider_config_id, folder, folder_id, tags, ai_mode, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
ch.ID, ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt,
|
||||
models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID),
|
||||
tagsJSON, aiMode, now.Format(timeFmt), now.Format(timeFmt),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateFull insert channel: %w", err)
|
||||
}
|
||||
|
||||
// Add owner participant
|
||||
_, _ = tx.ExecContext(ctx, `
|
||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||
VALUES (?, ?, 'user', ?, 'owner')
|
||||
ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, ownerUserID)
|
||||
|
||||
// Add DM partner participants
|
||||
for _, pid := range dmPartnerIDs {
|
||||
if pid == ownerUserID {
|
||||
continue
|
||||
}
|
||||
_, _ = tx.ExecContext(ctx, `
|
||||
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||
VALUES (?, ?, 'user', ?, 'member')
|
||||
ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, pid)
|
||||
}
|
||||
|
||||
// Auto-create channel_model if model specified
|
||||
if defaultModel != "" {
|
||||
_, _ = tx.ExecContext(ctx, `
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT DO NOTHING`, store.NewID(), 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 = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`,
|
||||
string(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, settings string
|
||||
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, st(&item.CreatedAtTime), st(&item.UpdatedAtTime),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Tags = ScanArray(tags)
|
||||
if item.Tags == nil {
|
||||
item.Tags = []string{}
|
||||
}
|
||||
item.Settings = safeJSONString(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 safeJSONString(s string) json.RawMessage {
|
||||
if s == "" || s == "null" {
|
||||
return json.RawMessage("{}")
|
||||
}
|
||||
return json.RawMessage(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user