Files
pad/internal/store/attachments.go
T
xarmian 504d348917 feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)

Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.

Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
  attached/unattached, collection_id) + sort allowlist (size, filename,
  created_at — each with desc variant). LEFT JOIN to items + collections
  enriches each row with item_title/slug + collection_slug for the
  "in [[Item]]" link. Hides derived (thumbnail) rows by default — they
  count toward quota but are managed automatically and would clutter
  the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
  on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
  {attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
  derived rows directly (returns 400 with derived_attachment code) and
  invalidates the storage-usage cache.

Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
  (color thresholds at 80%/100%, override badge), 5-select filter row
  (category, item, collection, sort, page size), attachment list with
  thumbnails (image variants via thumb-sm, emoji icon otherwise), item
  link, MIME, size, date, and per-row delete with confirm() dialog.
  Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.

Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
  limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
  the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
  storage usage drops to 0 (cache invalidation hook fires) → second
  delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
  directly via the API.

Parent: PLAN-866.

* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)

Three findings from Codex on PR #303 round 1:

P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.

Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.

P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.

P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".

Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
  restricted to one collection sees only that collection's row +
  orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
  archive/other each return exactly the matching MIME types.

* fix(attachments): item-level visibility on list + delete per Codex (round 2)

Two more findings from Codex on PR #303 round 2:

1. The list filter used VisibleCollectionIDs alone — but that set
   includes collections containing any item-level grant for the user.
   A guest with one item granted in collection B would still receive
   attachment metadata for every item in collection B. Replaced with
   the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
   the SQL ORs collection-level full access against per-item grants,
   matching how handlers_search / handlers_activity narrow lists.

2. The delete endpoint validated workspace membership but never
   checked the attachment's parent item is visible to the caller.
   An editor with restricted collection access could delete
   attachments in hidden collections by guessing/obtaining the
   attachment ID. Added requireItemVisible after fetching the parent
   item, plus a fallback gate for orphan attachments (item_id IS
   NULL) so restricted users get 404 there as well.

Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.

* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)

Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.

Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.

Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.

* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)

Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.

Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.

UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).

Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
  restricted-to-correct-collection sees it, restricted-to-other-
  collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
  to pass on the handler side.)
2026-04-29 17:44:12 -04:00

672 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// WorkspaceStorageInfo is the consolidated quota summary for a workspace:
// used bytes (from the attachments table), the effective limit for the
// owner's plan (after override + platform-setting + hardcoded fallback),
// the plan name, and whether the owner has a per-user storage_bytes
// override configured. -1 in LimitBytes means unlimited.
//
// The override_active flag tracks whether the workspace owner's
// PlanOverrides JSON contains a storage_bytes key — independent of
// whether that override actually changes the effective limit. (On
// pro/self-hosted plans the limit is unlimited regardless; the flag
// still surfaces the admin's intent in the Settings → Storage UI.)
type WorkspaceStorageInfo struct {
UsedBytes int64 `json:"used_bytes"`
LimitBytes int64 `json:"limit_bytes"` // -1 = unlimited
Plan string `json:"plan"`
OverrideActive bool `json:"override_active"`
}
// attachmentColumns is the canonical column list. Keep the column names in
// alignment with migrations/047_attachments.sql + pgmigrations/026_attachments.sql.
const attachmentColumns = `id, workspace_id, item_id, uploaded_by, storage_key, content_hash,
mime_type, size_bytes, filename, width, height, parent_id, variant, created_at, deleted_at`
// scanAttachment scans a single row into a models.Attachment, handling
// nullables via *string / *int.
func scanAttachment(row interface {
Scan(dest ...any) error
}) (*models.Attachment, error) {
var a models.Attachment
var itemID, parentID, variant, deletedAt *string
var width, height *int
var createdAt string
err := row.Scan(
&a.ID, &a.WorkspaceID, &itemID, &a.UploadedBy, &a.StorageKey, &a.ContentHash,
&a.MimeType, &a.SizeBytes, &a.Filename, &width, &height,
&parentID, &variant, &createdAt, &deletedAt,
)
if err != nil {
return nil, err
}
a.ItemID = itemID
a.ParentID = parentID
a.Variant = variant
a.Width = width
a.Height = height
a.CreatedAt = parseTime(createdAt)
a.DeletedAt = parseTimePtr(deletedAt)
return &a, nil
}
// CreateAttachment inserts an attachment row. id and created_at are
// generated by the store. The caller is expected to have already written
// the blob via AttachmentStore.Put — store-level code does not touch the
// filesystem.
func (s *Store) CreateAttachment(a *models.Attachment) error {
if a.ID == "" {
a.ID = newID()
}
ts := now()
if a.CreatedAt.IsZero() {
a.CreatedAt = parseTime(ts)
} else {
ts = a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
}
_, err := s.db.Exec(s.q(`
INSERT INTO attachments (`+attachmentColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
a.ID, a.WorkspaceID, a.ItemID, a.UploadedBy, a.StorageKey, a.ContentHash,
a.MimeType, a.SizeBytes, a.Filename, a.Width, a.Height,
a.ParentID, a.Variant, ts, nil,
)
if err != nil {
return fmt.Errorf("create attachment: %w", err)
}
return nil
}
// GetAttachment returns the attachment with the given id, or (nil, nil)
// if no such row exists. Soft-deleted rows ARE returned (so download
// handlers can distinguish "deleted" from "never existed" and return a
// 410 instead of a 404 if they want to in the future); callers that
// want only live rows should check a.DeletedAt.
func (s *Store) GetAttachment(id string) (*models.Attachment, error) {
a, err := scanAttachment(s.db.QueryRow(s.q(`
SELECT `+attachmentColumns+` FROM attachments WHERE id = ?
`), id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get attachment: %w", err)
}
return a, nil
}
// GetAttachmentVariant returns the derived attachment row for parentID
// with the given variant key (e.g. "thumb-sm"), or (nil, nil) if no such
// row exists. Used by the download handler when a client passes
// ?variant=thumb-sm — TASK-878 will populate these rows; TASK-872
// implements the lookup so the handler degrades gracefully when no
// thumbnail exists yet.
func (s *Store) GetAttachmentVariant(parentID, variant string) (*models.Attachment, error) {
a, err := scanAttachment(s.db.QueryRow(s.q(`
SELECT `+attachmentColumns+` FROM attachments
WHERE parent_id = ? AND variant = ? AND deleted_at IS NULL
LIMIT 1
`), parentID, variant))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get attachment variant: %w", err)
}
return a, nil
}
// AttachmentListFilters narrow the WorkspaceAttachments result set.
// Zero values mean "no filter on this dimension". The handler turns
// query-string parameters into this struct so the SQL builder stays
// pure and unit-testable.
type AttachmentListFilters struct {
// MimeCategory restricts to a single MIME category bucket
// (matches attachments.Category — "image", "document", etc.).
// Empty = all categories. Translated to MIME predicates per
// mimePredicateForCategory; categories without a clean prefix
// (document, text, archive, other) use an explicit IN list.
MimeCategory string
// Attached restricts to attachments associated with an item.
Attached bool
// Unattached restricts to orphan attachments (item_id IS NULL).
// Mutually exclusive with Attached — handler validates upstream.
Unattached bool
// CollectionID restricts to attachments belonging to items in
// this collection. Empty = no collection filter.
CollectionID string
// Sort field. Accepts "size", "filename", "created_at" with an
// optional " desc" suffix. Empty = "created_at desc" (newest first).
Sort string
// Limit caps the page size. Clamped to [1, 200] by the handler.
Limit int
// Offset pages forward. Combined with Limit + Total for the UI's
// classic page navigator. Negative values clamped to 0.
Offset int
// Restricted, FullCollectionIDs, GrantedItemIDs together encode
// per-user collection + item visibility. When Restricted is true
// the list filters to attachments whose parent item is either
// in one of FullCollectionIDs or has its id in GrantedItemIDs.
// Orphans (item_id IS NULL) are excluded from restricted views
// so a member who only sees one collection can't enumerate
// filenames of unattached uploads from collections they don't
// have access to.
//
// Restricted=false → no filter (admin / full-access member).
// Restricted=true with empty Full+Granted → zero rows.
// Restricted=true with one or both populated → SQL OR of the
// two predicates.
//
// Mirrors the (fullCollIDs, grantedItemIDs) tuple returned by
// Server.guestResourceFilter — keep the semantics in sync.
Restricted bool
FullCollectionIDs []string
GrantedItemIDs []string
}
// AttachmentListItem is a row from WorkspaceAttachments enriched with
// the parent item's title + slug + collection slug so the UI can render
// a clickable link without a follow-up GET. Item fields are nullable
// for orphan rows.
//
// URL construction: the item route is /{user}/{ws}/{collection_slug}/{item_slug},
// so the UI uses ItemSlug — never a synthetic "TASK-5"-style ref. The
// ref shape isn't 1:1 with the route, and exposing it here led to a
// double-collection-slug bug in an earlier draft.
//
// ItemDeleted is true when the parent item exists but has been soft-
// deleted. The row is still surfaced because the bytes still consume
// quota; the UI uses the flag to render "(deleted)" instead of a
// clickable link to a 404'd item.
type AttachmentListItem struct {
models.Attachment
ItemTitle *string `json:"item_title,omitempty"`
ItemSlug *string `json:"item_slug,omitempty"`
ItemDeleted bool `json:"item_deleted,omitempty"`
CollectionSlug *string `json:"collection_slug,omitempty"`
}
// allowedAttachmentSorts pins the columns + directions the list
// endpoint accepts, so a hand-crafted sort= query can't smuggle in
// arbitrary SQL. Map values are the literal SQL fragment we splice in.
var allowedAttachmentSorts = map[string]string{
"size": "a.size_bytes ASC",
"size_desc": "a.size_bytes DESC",
"filename": "a.filename ASC",
"filename_desc": "a.filename DESC",
"created_at": "a.created_at ASC",
"created_at_desc": "a.created_at DESC",
}
// mimePredicateForCategory maps an attachments.Category value to a
// SQL fragment + matching argument list that selects all MIMEs in
// that bucket. Categories with a clean type prefix (image/, video/,
// audio/) use a LIKE; the rest use an explicit IN list mirroring
// the entries in internal/attachments/mime.go.
//
// Returns (frag, args, true) when the category is known. The frag
// uses ? placeholders that the caller splices into the WHERE. ok=false
// for unknown categories — caller must skip the filter so the UI
// shows everything rather than zero rows for typos.
//
// Keep the literal MIME lists in lockstep with internal/attachments/
// mime.go: any time a new MIME is added to the allowlist there, mirror
// it here so the Settings → Storage filter stays useful.
func mimePredicateForCategory(category string) (frag string, args []any, ok bool) {
switch category {
case "image":
return "a.mime_type LIKE ?", []any{"image/%"}, true
case "video":
return "a.mime_type LIKE ?", []any{"video/%"}, true
case "audio":
return "a.mime_type LIKE ?", []any{"audio/%"}, true
case "document":
return mimeInPredicate([]string{
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/rtf",
})
case "text":
return mimeInPredicate([]string{
"text/plain", "text/markdown", "text/csv", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"application/yaml", "text/yaml", "application/toml",
"text/html", "text/javascript", "application/javascript",
})
case "archive":
return mimeInPredicate([]string{
"application/zip", "application/x-tar", "application/gzip",
"application/x-bzip2", "application/x-7z-compressed",
})
case "other":
// "Other" is the negation of every named bucket. Easier to
// build by exclusion: NOT in the union of all known MIMEs.
// Listing the categories explicitly keeps this in lockstep
// with the named buckets above without a second source of
// truth.
all := []string{
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/rtf",
"text/plain", "text/markdown", "text/csv", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"application/yaml", "text/yaml", "application/toml",
"text/html", "text/javascript", "application/javascript",
"application/zip", "application/x-tar", "application/gzip",
"application/x-bzip2", "application/x-7z-compressed",
}
placeholders := make([]string, len(all))
args := make([]any, len(all))
for i, m := range all {
placeholders[i] = "?"
args[i] = m
}
frag := "a.mime_type NOT LIKE 'image/%' AND a.mime_type NOT LIKE 'video/%' AND a.mime_type NOT LIKE 'audio/%' AND a.mime_type NOT IN (" + strings.Join(placeholders, ",") + ")"
return frag, args, true
}
return "", nil, false
}
// mimeInPredicate builds a `a.mime_type IN (?,?,...)` fragment with
// matching args. Helper used by mimePredicateForCategory for the
// non-prefix categories.
func mimeInPredicate(mimes []string) (string, []any, bool) {
placeholders := make([]string, len(mimes))
args := make([]any, len(mimes))
for i, m := range mimes {
placeholders[i] = "?"
args[i] = m
}
return "a.mime_type IN (" + strings.Join(placeholders, ",") + ")", args, true
}
// WorkspaceAttachments lists original (non-derived) attachments in a
// workspace, with optional filtering + sorting + pagination. Returns
// the page rows and the total count of matching rows so the UI can
// render a paginator without a second round-trip.
//
// Intentionally hides derived blobs (thumbnails — rows where
// parent_id IS NOT NULL): they're managed automatically and showing
// them in the list would clutter the page with rows the user didn't
// upload. They still count against storage quota via
// WorkspaceStorageUsage; the totals match the bar even when the list
// shows only originals.
//
// LEFT JOIN to items + collections gives the UI everything it needs
// to render an "in [[Task X]]" link. Soft-deleted items are returned
// with item fields nulled out — the attachment is still visible
// (a deleted item could still be restored), but the link target
// isn't reachable.
func (s *Store) WorkspaceAttachments(workspaceID string, filters AttachmentListFilters) ([]AttachmentListItem, int, error) {
// Build the WHERE clause incrementally. Every branch parameter
// goes through the placeholder slice — no string concatenation of
// user input. Sort is the only user-controllable splice and it
// goes through the allowedAttachmentSorts allowlist.
var conds []string
var args []any
conds = append(conds, "a.workspace_id = ?")
args = append(args, workspaceID)
conds = append(conds, "a.deleted_at IS NULL")
conds = append(conds, "a.parent_id IS NULL") // hide derived blobs
if filters.Attached {
conds = append(conds, "a.item_id IS NOT NULL")
}
if filters.Unattached {
conds = append(conds, "a.item_id IS NULL")
}
if frag, mimeArgs, ok := mimePredicateForCategory(filters.MimeCategory); ok {
conds = append(conds, frag)
args = append(args, mimeArgs...)
}
if filters.CollectionID != "" {
conds = append(conds, "i.collection_id = ?")
args = append(args, filters.CollectionID)
}
// Collection + item-level visibility enforcement. Two sources of
// access: collections the user can see in full (FullCollectionIDs)
// and individual items granted to them (GrantedItemIDs). The
// predicate ORs them so an item-grant in a hidden collection still
// resolves; orphans (item_id IS NULL) are excluded entirely so a
// restricted user can't enumerate orphan filenames.
//
// Restricted=false → no filter (admin / full-access member).
// Restricted=true with both lists empty → zero rows.
if filters.Restricted {
var ors []string
if len(filters.FullCollectionIDs) > 0 {
ph := make([]string, len(filters.FullCollectionIDs))
for i, id := range filters.FullCollectionIDs {
ph[i] = "?"
args = append(args, id)
}
ors = append(ors, "i.collection_id IN ("+strings.Join(ph, ",")+")")
}
if len(filters.GrantedItemIDs) > 0 {
ph := make([]string, len(filters.GrantedItemIDs))
for i, id := range filters.GrantedItemIDs {
ph[i] = "?"
args = append(args, id)
}
ors = append(ors, "a.item_id IN ("+strings.Join(ph, ",")+")")
}
if len(ors) == 0 {
conds = append(conds, "1 = 0")
} else {
conds = append(conds, "("+strings.Join(ors, " OR ")+")")
}
}
where := strings.Join(conds, " AND ")
// Count total before applying limit/offset so the UI can render
// "showing 125 of 312".
//
// The items LEFT JOIN intentionally does NOT filter on
// items.deleted_at — attachments survive a soft-deleted parent
// (they still consume quota), so the storage list must surface
// them and the collection-level visibility predicate
// (i.collection_id IN ...) must keep working. The handler's
// delete path uses GetItemIncludeDeleted for the same reason.
var total int
if err := s.db.QueryRow(s.q(`
SELECT COUNT(*) FROM attachments a
LEFT JOIN items i ON i.id = a.item_id
WHERE `+where), args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count workspace attachments: %w", err)
}
orderBy, ok := allowedAttachmentSorts[filters.Sort]
if !ok {
orderBy = "a.created_at DESC" // sensible default — newest first
}
limit := filters.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
offset := filters.Offset
if offset < 0 {
offset = 0
}
// Column list is the same as attachmentColumns but prefixed with
// `a.` so SQLite doesn't choke on the ambiguous `id` shared with
// the joined items table.
const aliasedAttachmentColumns = `a.id, a.workspace_id, a.item_id, a.uploaded_by, a.storage_key, a.content_hash,
a.mime_type, a.size_bytes, a.filename, a.width, a.height, a.parent_id, a.variant, a.created_at, a.deleted_at`
// Same rationale as the count query above: include soft-deleted
// parent items so the row is still visible for users who would
// be allowed to see the live item, and so the collection-level
// ACL predicate sees a non-NULL i.collection_id. The response
// surfaces deleted_at on the joined item via item_deleted so the
// UI can render a "(deleted)" tag instead of a clickable link.
q := `
SELECT ` + aliasedAttachmentColumns + `,
i.title, i.slug, i.deleted_at,
c.slug, c.name
FROM attachments a
LEFT JOIN items i ON i.id = a.item_id
LEFT JOIN collections c ON c.id = i.collection_id
WHERE ` + where + `
ORDER BY ` + orderBy + `
LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, 0, fmt.Errorf("list workspace attachments: %w", err)
}
defer rows.Close()
var out []AttachmentListItem
for rows.Next() {
var a models.Attachment
var itemID, parentID, variant, deletedAt *string
var width, height *int
var createdAt string
// Item + collection columns from the LEFT JOIN. All nullable.
var itemTitle, itemSlug, itemDeletedAt *string
var collSlug, collName *string
if err := rows.Scan(
&a.ID, &a.WorkspaceID, &itemID, &a.UploadedBy, &a.StorageKey, &a.ContentHash,
&a.MimeType, &a.SizeBytes, &a.Filename, &width, &height,
&parentID, &variant, &createdAt, &deletedAt,
&itemTitle, &itemSlug, &itemDeletedAt,
&collSlug, &collName,
); err != nil {
return nil, 0, fmt.Errorf("scan workspace attachment: %w", err)
}
a.ItemID = itemID
a.ParentID = parentID
a.Variant = variant
a.Width = width
a.Height = height
a.CreatedAt = parseTime(createdAt)
a.DeletedAt = parseTimePtr(deletedAt)
row := AttachmentListItem{Attachment: a}
if itemTitle != nil {
row.ItemTitle = itemTitle
}
if itemSlug != nil {
row.ItemSlug = itemSlug
}
if itemDeletedAt != nil && *itemDeletedAt != "" {
row.ItemDeleted = true
}
if collSlug != nil {
row.CollectionSlug = collSlug
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate workspace attachments: %w", err)
}
return out, total, nil
}
// SoftDeleteAttachment marks the given attachment row deleted (and
// every variant whose parent_id points at it) so the orphan GC will
// reclaim the bytes after the grace period. Returns sql.ErrNoRows if
// no live row matches.
//
// We mark variants via a separate UPDATE keyed on parent_id rather
// than letting a foreign-key cascade do it — the attachments table
// has no FK on parent_id, by design (DOC-865: thumbnails are
// independent rows so a missing original doesn't break a list query).
//
// The blob on disk is NOT removed here; the same content_hash may be
// referenced by other rows (content-addressed dedupe), so reclamation
// is the GC's job once it can prove no live row references the hash.
func (s *Store) SoftDeleteAttachment(id string) error {
ts := now()
res, err := s.db.Exec(s.q(`
UPDATE attachments
SET deleted_at = ?
WHERE id = ? AND deleted_at IS NULL
`), ts, id)
if err != nil {
return fmt.Errorf("soft delete attachment: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("soft delete attachment rows affected: %w", err)
}
if n == 0 {
return sql.ErrNoRows
}
// Also tombstone any thumbnail variants. They're synthetic rows
// derived from the original; without the original they have no
// reason to exist. Errors here are non-fatal — orphan GC will
// still reach them eventually via the deleted-parent path.
if _, err := s.db.Exec(s.q(`
UPDATE attachments
SET deleted_at = ?
WHERE parent_id = ? AND deleted_at IS NULL
`), ts, id); err != nil {
// Log via the caller; we don't have a logger here. The row
// went through, so don't fail the request.
return nil
}
return nil
}
// WorkspaceStorageUsage returns the total bytes consumed by non-deleted
// attachments in the workspace. Includes derived blobs (thumbnails) —
// those are real bytes on disk and count against quota.
func (s *Store) WorkspaceStorageUsage(workspaceID string) (int64, error) {
var total sql.NullInt64
err := s.db.QueryRow(s.q(`
SELECT COALESCE(SUM(size_bytes), 0)
FROM attachments
WHERE workspace_id = ? AND deleted_at IS NULL
`), workspaceID).Scan(&total)
if err != nil {
return 0, fmt.Errorf("workspace storage usage: %w", err)
}
return total.Int64, nil
}
// WorkspaceStorageLimit returns the effective storage limit (bytes) for
// the workspace owner's plan, or -1 if the plan is unlimited. Thin
// wrapper around WorkspaceStorageInfo for the upload-time quota check
// path that doesn't care about used bytes / plan / override metadata.
//
// Resolution matches the CheckLimit three-tier order:
// 1. Per-user override (user.PlanOverrides JSON, key "storage_bytes")
// 2. Platform setting (plan_limits_<plan>_storage_bytes)
// 3. Hardcoded fallback (DefaultFreeLimits / DefaultProLimits)
//
// CheckLimit itself can't be reused here because its featureCount path
// only knows about row-counted features (items, members, webhooks, …) —
// storage_bytes is byte-counted via WorkspaceStorageUsage.
func (s *Store) WorkspaceStorageLimit(workspaceID string) (int64, error) {
info, err := s.WorkspaceStorageInfo(workspaceID)
if err != nil {
return 0, err
}
return info.LimitBytes, nil
}
// WorkspaceStorageInfo returns the consolidated quota summary for a
// workspace (used + limit + plan + override flag) in a single call.
// Used by the storage usage API (TASK-881) and the Settings → Storage
// page (TASK-882) so the UI can render the usage bar and the
// "(override)" badge from one fetch.
//
// Limit resolution mirrors WorkspaceStorageLimit; pro and self-hosted
// plans return -1 (unlimited) in Phase 1. Workspaces with no owner
// (fresh install, deleted owner) also resolve to unlimited rather than
// blocking uploads.
func (s *Store) WorkspaceStorageInfo(workspaceID string) (*WorkspaceStorageInfo, error) {
used, err := s.WorkspaceStorageUsage(workspaceID)
if err != nil {
return nil, err
}
info := &WorkspaceStorageInfo{
UsedBytes: used,
LimitBytes: -1, // default: no enforceable limit (no owner / pro / self-hosted)
}
var ownerID sql.NullString
err = s.db.QueryRow(s.q(`SELECT owner_id FROM workspaces WHERE id = ?`), workspaceID).Scan(&ownerID)
if err == sql.ErrNoRows {
return info, nil
}
if err != nil {
return nil, fmt.Errorf("workspace storage info: get owner: %w", err)
}
// Workspaces created on a fresh install (no users yet) and legacy
// workspaces from before owner_id was added have no owner. In both
// cases there is no plan to consult — treat as unlimited rather
// than rejecting uploads.
if !ownerID.Valid || ownerID.String == "" {
return info, nil
}
user, err := s.GetUser(ownerID.String)
if err != nil {
return nil, fmt.Errorf("workspace storage info: get user: %w", err)
}
if user == nil {
// Owner row was deleted but the workspace still references it.
return info, nil
}
plan := user.Plan
if plan == "" {
plan = "free"
}
info.Plan = plan
// Detect whether a per-user storage_bytes override is configured.
// We surface this as a flag for the UI even when the plan is
// pro/self-hosted (where the override has no effect on the
// effective limit) so admins can see the configured value.
if user.PlanOverrides != "" {
var overrides map[string]int
if err := json.Unmarshal([]byte(user.PlanOverrides), &overrides); err == nil {
if _, ok := overrides["storage_bytes"]; ok {
info.OverrideActive = true
}
}
}
// Pro and self-hosted have no enforced limit in Phase 1.
if plan == "pro" || plan == "self-hosted" {
return info, nil
}
// resolveLimit returns int (32-bit on 32-bit platforms) so we can't
// store a quota larger than 2 GiB on 32-bit hosts via this path.
// That's a non-issue for Phase 1: hardcoded free=500MB, pro=10GB,
// and 32-bit production hosts are not a supported deployment target
// for Pad Cloud. If a future plan-tier ever exceeds INT_MAX bytes
// we'll widen resolveLimit.
info.LimitBytes = int64(s.resolveLimit(plan, "storage_bytes", user.PlanOverrides))
return info, nil
}