package postgres import ( "context" "database/sql" "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) type WorkspaceStore struct{} func NewWorkspaceStore() *WorkspaceStore { return &WorkspaceStore{} } // ── columns ──────────────────────────────── const workspaceCols = `id, owner_type, owner_id, name, root_path, max_bytes, status, created_at, updated_at` const workspaceFileCols = `id, workspace_id, path, is_directory, content_type, size_bytes, sha256, created_at, updated_at` // ── scanners ─────────────────────────────── func scanWorkspace(row interface{ Scan(dest ...interface{}) error }) (*models.Workspace, error) { var w models.Workspace var maxBytes sql.NullInt64 err := row.Scan( &w.ID, &w.OwnerType, &w.OwnerID, &w.Name, &w.RootPath, &maxBytes, &w.Status, &w.CreatedAt, &w.UpdatedAt, ) if err != nil { return nil, err } if maxBytes.Valid { w.MaxBytes = &maxBytes.Int64 } return &w, nil } func scanWorkspaceFile(row interface{ Scan(dest ...interface{}) error }) (*models.WorkspaceFile, error) { var f models.WorkspaceFile var contentType sql.NullString var sha256 sql.NullString err := row.Scan( &f.ID, &f.WorkspaceID, &f.Path, &f.IsDirectory, &contentType, &f.SizeBytes, &sha256, &f.CreatedAt, &f.UpdatedAt, ) if err != nil { return nil, err } if contentType.Valid { f.ContentType = contentType.String } if sha256.Valid { f.SHA256 = sha256.String } return &f, nil } // ── Workspace CRUD ───────────────────────── func (s *WorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { // Accept pre-generated ID or let Postgres generate one if w.ID != "" { return DB.QueryRowContext(ctx, ` INSERT INTO workspaces (id, owner_type, owner_id, name, root_path, max_bytes, status) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at, updated_at`, w.ID, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, ).Scan(&w.CreatedAt, &w.UpdatedAt) } return DB.QueryRowContext(ctx, ` INSERT INTO workspaces (owner_type, owner_id, name, root_path, max_bytes, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at`, w.OwnerType, w.OwnerID, w.Name, w.RootPath, w.MaxBytes, w.Status, ).Scan(&w.ID, &w.CreatedAt, &w.UpdatedAt) } func (s *WorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE id = $1`, id) return scanWorkspace(row) } func (s *WorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { b := NewUpdate("workspaces") if patch.Name != nil { b.Set("name", *patch.Name) } if patch.MaxBytes != nil { b.Set("max_bytes", *patch.MaxBytes) } if patch.Status != nil { b.Set("status", *patch.Status) } if !b.HasSets() { return nil } b.Set("updated_at", time.Now().UTC()) b.Where("id", id) _, err := b.Exec(DB) return err } func (s *WorkspaceStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspaces WHERE id = $1`, id) return err } // ── Ownership lookup ─────────────────────── func (s *WorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' ORDER BY created_at DESC LIMIT 1`, ownerType, ownerID) return scanWorkspace(row) } func (s *WorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { rows, err := DB.QueryContext(ctx, `SELECT `+workspaceCols+` FROM workspaces WHERE owner_type = $1 AND owner_id = $2 AND status != 'deleting' ORDER BY created_at DESC`, ownerType, ownerID) if err != nil { return nil, err } defer rows.Close() var out []models.Workspace for rows.Next() { w, err := scanWorkspace(rows) if err != nil { return nil, err } out = append(out, *w) } return out, rows.Err() } // ── File index ───────────────────────────── func (s *WorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { return DB.QueryRowContext(ctx, ` INSERT INTO workspace_files (workspace_id, path, is_directory, content_type, size_bytes, sha256) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, path) DO UPDATE SET is_directory = EXCLUDED.is_directory, content_type = EXCLUDED.content_type, size_bytes = EXCLUDED.size_bytes, sha256 = EXCLUDED.sha256, updated_at = NOW() RETURNING id, created_at, updated_at`, f.WorkspaceID, f.Path, f.IsDirectory, models.NullString(&f.ContentType), f.SizeBytes, models.NullString(&f.SHA256), ).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt) } func (s *WorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1 AND path = $2`, workspaceID, path) return err } func (s *WorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2`, workspaceID, prefix+"%") return err } func (s *WorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { row := DB.QueryRowContext(ctx, `SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = $1 AND path = $2`, workspaceID, path) return scanWorkspaceFile(row) } func (s *WorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { var query string var args []interface{} if recursive { // All files under prefix (recursive) if prefix == "" { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 ORDER BY path` args = []interface{}{workspaceID} } else { query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2 ORDER BY path` args = []interface{}{workspaceID, prefix + "%"} } } else { // Direct children only: match prefix but no additional slashes if prefix == "" { // Root level: no slash in path query = `SELECT ` + workspaceFileCols + ` FROM workspace_files WHERE workspace_id = $1 AND path NOT LIKE '%/%' ORDER BY path` args = []interface{}{workspaceID} } else { // Children of prefix: starts with prefix, no additional slash after prefix query = fmt.Sprintf(`SELECT `+workspaceFileCols+` FROM workspace_files WHERE workspace_id = $1 AND path LIKE $2 AND substring(path FROM %d) NOT LIKE '%%/%%' ORDER BY path`, len(prefix)+1) args = []interface{}{workspaceID, prefix + "%"} } } rows, err := DB.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var out []models.WorkspaceFile for rows.Next() { f, err := scanWorkspaceFile(rows) if err != nil { return nil, err } out = append(out, *f) } return out, rows.Err() } func (s *WorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { _, err := DB.ExecContext(ctx, `DELETE FROM workspace_files WHERE workspace_id = $1`, workspaceID) return err } // ── Stats ────────────────────────────────── func (s *WorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { stats := &models.WorkspaceStats{WorkspaceID: workspaceID} err := DB.QueryRowContext(ctx, ` SELECT COALESCE(SUM(CASE WHEN NOT is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN is_directory THEN 1 ELSE 0 END), 0), COALESCE(SUM(size_bytes), 0) FROM workspace_files WHERE workspace_id = $1`, workspaceID, ).Scan(&stats.FileCount, &stats.DirCount, &stats.TotalBytes) if err != nil { return nil, err } // Fetch quota var maxBytes sql.NullInt64 err = DB.QueryRowContext(ctx, `SELECT max_bytes FROM workspaces WHERE id = $1`, workspaceID, ).Scan(&maxBytes) if err != nil && err != sql.ErrNoRows { return nil, err } if maxBytes.Valid { stats.MaxBytes = &maxBytes.Int64 } return stats, nil }