package store import ( "database/sql" "fmt" "time" "github.com/PerpetualSoftware/pad/internal/models" ) // ListWorkspaces returns every non-deleted workspace, unordered by user. // This is intended for admin-panel cross-tenant views and the // pre-auth/fresh-install bootstrap. End-user workspace switchers should // call GetUserWorkspaces instead, which scopes to the user's memberships. func (s *Store) ListWorkspaces() ([]models.Workspace, error) { // BUG-1481: workspaces.updated_at only moves when the workspace row // itself changes (rename, settings, members) — it does NOT reflect // item activity inside the workspace. We surface the latter via // MAX(items.updated_at) and expose the later of the two as the // workspace's effective UpdatedAt, so `pad workspace list` answers // "where is work happening?" rather than "when was this row last // renamed?". Done in two steps (scalar subquery + Go-side max) to // stay portable across SQLite (no GREATEST) and Postgres. rows, err := s.db.Query(s.q(` SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, (SELECT MAX(i.updated_at) FROM items i WHERE i.workspace_id = w.id AND i.deleted_at IS NULL) FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.deleted_at IS NULL ORDER BY w.name ASC `)) if err != nil { return nil, err } defer rows.Close() var workspaces []models.Workspace for rows.Next() { var w models.Workspace var createdAt, updatedAt string var lastItemActivity sql.NullString if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &lastItemActivity); err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = effectiveWorkspaceUpdatedAt(updatedAt, lastItemActivity) w.HydrateDerivedFields() workspaces = append(workspaces, w) } return workspaces, rows.Err() } // effectiveWorkspaceUpdatedAt returns the later of the workspace's own // updated_at and the most recent item activity inside it. See BUG-1481 // for why item activity is the meaningful freshness signal. func effectiveWorkspaceUpdatedAt(workspaceUpdatedAt string, lastItemActivity sql.NullString) time.Time { wsTS := parseTime(workspaceUpdatedAt) if !lastItemActivity.Valid || lastItemActivity.String == "" { return wsTS } itemTS := parseTime(lastItemActivity.String) if itemTS.After(wsTS) { return itemTS } return wsTS } func (s *Store) CreateWorkspace(input models.WorkspaceCreate) (*models.Workspace, error) { id := newID() ts := now() slug := input.Slug if slug == "" { slug = slugify(input.Name) } // Workspace slugs are globally unique (not scoped to a workspace // like collection/item slugs), so we use a workspace-specific // uniqueness check rather than the generic uniqueSlug helper. finalSlug, err := s.uniqueWorkspaceSlug(slug) if err != nil { return nil, err } settings := input.Settings if settings == "" { settings = "{}" } settings, err = models.NormalizeWorkspaceSettings(settings) if err != nil { return nil, fmt.Errorf("normalize workspace settings: %w", err) } if input.Context != nil { settings, err = models.ApplyWorkspaceContext(settings, input.Context) if err != nil { return nil, fmt.Errorf("apply workspace context: %w", err) } } _, err = s.db.Exec(s.q(` INSERT INTO workspaces (id, name, slug, owner_id, description, settings, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `), id, input.Name, finalSlug, input.OwnerID, input.Description, settings, input.Source, ts, ts) if err != nil { return nil, fmt.Errorf("insert workspace: %w", err) } return s.GetWorkspaceBySlug(finalSlug) } func (s *Store) uniqueWorkspaceSlug(baseSlug string) (string, error) { slug := baseSlug for i := 2; ; i++ { var count int err := s.db.QueryRow(s.q("SELECT COUNT(*) FROM workspaces WHERE slug = ? AND deleted_at IS NULL"), slug).Scan(&count) if err != nil { return "", err } if count == 0 { return slug, nil } slug = fmt.Sprintf("%s-%d", baseSlug, i) } } func (s *Store) GetWorkspaceBySlug(slug string) (*models.Workspace, error) { var w models.Workspace var createdAt, updatedAt string var deletedAt *string err := s.db.QueryRow(s.q(` SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.slug = ? AND w.deleted_at IS NULL `), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = parseTime(updatedAt) w.DeletedAt = parseTimePtr(deletedAt) w.HydrateDerivedFields() return &w, nil } func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) { var w models.Workspace var createdAt, updatedAt string var deletedAt *string err := s.db.QueryRow(s.q(` SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.id = ? AND w.deleted_at IS NULL `), id).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = parseTime(updatedAt) w.DeletedAt = parseTimePtr(deletedAt) w.HydrateDerivedFields() return &w, nil } // GetWorkspacesBySlugForUser finds workspaces matching a slug that are accessible // to the given user (owned, member, or guest with grants). func (s *Store) GetWorkspacesBySlugForUser(slug, userID string) ([]models.Workspace, error) { rows, err := s.db.Query(s.q(` SELECT DISTINCT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? LEFT JOIN collection_grants cg ON cg.workspace_id = w.id AND cg.user_id = ? LEFT JOIN item_grants ig ON ig.workspace_id = w.id AND ig.user_id = ? LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.slug = ? AND w.deleted_at IS NULL AND (w.owner_id = ? OR wm.user_id IS NOT NULL OR cg.user_id IS NOT NULL OR ig.user_id IS NOT NULL) `), userID, userID, userID, slug, userID) if err != nil { return nil, fmt.Errorf("get workspaces by slug for user: %w", err) } defer rows.Close() var result []models.Workspace for rows.Next() { var w models.Workspace var createdAt, updatedAt string if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt); err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = parseTime(updatedAt) w.HydrateDerivedFields() result = append(result, w) } return result, rows.Err() } func (s *Store) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*models.Workspace, error) { w, err := s.GetWorkspaceBySlug(slug) if err != nil { return nil, err } if w == nil { return nil, nil } ts := now() if input.Name != nil { w.Name = *input.Name } if input.Description != nil { w.Description = *input.Description } if input.Settings != nil { w.Settings = *input.Settings } if input.Context != nil { settings, err := models.ApplyWorkspaceContext(w.Settings, input.Context) if err != nil { return nil, fmt.Errorf("apply workspace context: %w", err) } w.Settings = settings } if w.Settings != "" { settings, err := models.NormalizeWorkspaceSettings(w.Settings) if err != nil { return nil, fmt.Errorf("normalize workspace settings: %w", err) } w.Settings = settings } _, err = s.db.Exec(s.q(` UPDATE workspaces SET name = ?, description = ?, settings = ?, updated_at = ? WHERE id = ? `), w.Name, w.Description, w.Settings, ts, w.ID) if err != nil { return nil, err } return s.GetWorkspaceBySlug(slug) } func (s *Store) DeleteWorkspace(slug string) error { ts := now() result, err := s.db.Exec(s.q(` UPDATE workspaces SET deleted_at = ?, updated_at = ? WHERE slug = ? AND deleted_at IS NULL `), ts, ts, slug) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return sql.ErrNoRows } return nil } // RestoreWorkspace un-soft-deletes a workspace: it clears deleted_at so // the workspace (and every item/collection/member/attachment that was // only transitively hidden by the `w.deleted_at IS NULL` filters) // re-surfaces intact. Nothing below the workspace was ever soft-deleted // by DeleteWorkspace, so restoring the parent row is all it takes. // // The inverse of DeleteWorkspace and modeled on its shape. Idempotent- // ish: returns sql.ErrNoRows (→ 404 at the handler) when no soft-deleted // row matched the slug — i.e. the workspace is already live, or it was // hard-purged by the retention sweeper (workspace_purge.go) and is gone // for good. func (s *Store) RestoreWorkspace(slug string) error { ts := now() result, err := s.db.Exec(s.q(` UPDATE workspaces SET deleted_at = NULL, updated_at = ? WHERE slug = ? AND deleted_at IS NOT NULL `), ts, slug) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return sql.ErrNoRows } return nil } // GetDeletedWorkspaceBySlug returns a SOFT-DELETED workspace by slug // (deleted_at IS NOT NULL), including its owner_id, or nil when no such // row exists (live workspace, unknown slug, or already hard-purged). // // The normal resolvers (GetWorkspaceBySlug / resolveWorkspace) filter // `deleted_at IS NULL`, so a soft-deleted workspace is invisible to // them — the restore handler uses this to look up the row it's about to // restore and check ownership BEFORE mutating (so it can tell a // non-owner's 403 apart from a genuine 404). func (s *Store) GetDeletedWorkspaceBySlug(slug string) (*models.Workspace, error) { var w models.Workspace var createdAt, updatedAt string var deletedAt *string err := s.db.QueryRow(s.q(` SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.slug = ? AND w.deleted_at IS NOT NULL `), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = parseTime(updatedAt) w.DeletedAt = parseTimePtr(deletedAt) w.HydrateDerivedFields() return &w, nil } // ListDeletedWorkspaces returns the soft-deleted workspaces the given // user OWNS that are still inside the restore window — deleted_at set, // newer than cutoff (which the caller derives from the purge retention // constant so restore and purge share one horizon). Ordered // most-recently-deleted first. // // Owner-scoped by owner_id, the canonical "workspaces you solely own" // signal. Account-deleted workspaces have no live owner (the owner user // row was removed with the account), so their owner_id can't match any // live requester — they never leak into this list. That is the intended // behavior: only manually-deleted workspaces owned by a live user are // practically restorable. // // deleted_at is fixed-width RFC3339 UTC (store.now()), so the `> cutoff` // string comparison is a valid chronological ordering — the same // technique ListPurgeableWorkspaces uses for its `< cutoff` bound. func (s *Store) ListDeletedWorkspaces(userID string, cutoff time.Time) ([]models.Workspace, error) { cutoffStr := cutoff.UTC().Format(time.RFC3339) rows, err := s.db.Query(s.q(` SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at FROM workspaces w LEFT JOIN users ou ON ou.id = w.owner_id WHERE w.owner_id = ? AND w.deleted_at IS NOT NULL AND w.deleted_at > ? ORDER BY w.deleted_at DESC `), userID, cutoffStr) if err != nil { return nil, fmt.Errorf("list deleted workspaces: %w", err) } defer rows.Close() var result []models.Workspace for rows.Next() { var w models.Workspace var createdAt, updatedAt string var deletedAt *string if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt); err != nil { return nil, fmt.Errorf("scan deleted workspace: %w", err) } w.CreatedAt = parseTime(createdAt) w.UpdatedAt = parseTime(updatedAt) w.DeletedAt = parseTimePtr(deletedAt) w.HydrateDerivedFields() result = append(result, w) } return result, rows.Err() }