T-HUB/apps/api/migrations/035_submissions.sql
glass beadian 599a68cc41 feat: anonymous group submission boxes — public intake, CC licence, moderation
WS0 of the video-producer plan: every group can open a public,
accountless upload box (random token, rotatable, disable switch).
Submitters tick the Creative Commons qualifier; credit name and a
licensing contact are optional free text — contact is visible only in
the moderation surface and never copied onto assets. Intake sniffs
magic bytes (mp4/webm/jpeg/png), caps video 200 MiB / photo 50 MiB,
rate-limits 5/hour per salted IP hash (hash never rendered), dedups by
sha256, caps 5 pending per box, and quarantines everything as pending.
Group managers approve (video → video library via the register+probe
path, photo → group asset) or reject with a note; every step audited.
Migration 035 (RLS + submission_box_lookup/submission_rate_count
definer fns). 4 unit + 4 live-DB tests.
2026-10-14 13:32:00 +00:00

66 lines
3.2 KiB
PL/PgSQL

-- Anonymous group submission boxes (owner request): a public, accountless
-- intake for footage/photos. Submitters tick a Creative Commons qualifier;
-- credit name and a licensing contact are OPTIONAL free text. Submissions are
-- quarantined (pending) until a workspace group manager approves them.
CREATE TABLE submission_boxes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
group_id uuid NOT NULL,
public_token text NOT NULL UNIQUE,
license_default text NOT NULL DEFAULT 'CC BY-SA 4.0',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, workspace_id),
UNIQUE (id, workspace_id),
FOREIGN KEY (group_id, workspace_id) REFERENCES groups (id, workspace_id) ON DELETE CASCADE
);
CREATE POLICY tenant_isolation ON submission_boxes
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE TABLE submissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
box_id uuid NOT NULL,
group_id uuid NOT NULL,
kind text NOT NULL CHECK (kind IN ('video', 'photo')),
storage_key text NOT NULL,
mime text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes > 0),
sha256 text NOT NULL,
license text NOT NULL,
credit_name text CHECK (credit_name IS NULL OR (btrim(credit_name) <> '' AND length(credit_name) <= 100)),
contact text CHECK (contact IS NULL OR (btrim(contact) <> '' AND length(contact) <= 200)),
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'approved', 'rejected')),
rejection_note text CHECK (rejection_note IS NULL OR length(rejection_note) <= 500),
submitter_ip_hash text,
created_at timestamptz NOT NULL DEFAULT now(),
decided_at timestamptz,
decided_by uuid REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (box_id, workspace_id) REFERENCES submission_boxes (id, workspace_id) ON DELETE CASCADE
);
CREATE POLICY tenant_isolation ON submissions
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE INDEX submissions_box_state ON submissions (box_id, state, created_at);
CREATE INDEX submissions_rate ON submissions (submitter_ip_hash, created_at) WHERE submitter_ip_hash IS NOT NULL;
-- The public submit route is unauthenticated: the box lookup runs before any
-- tenant context exists, so it goes through a definer-rights function (the
-- audit_append / oidc_refresh_lookup pattern).
CREATE FUNCTION submission_box_lookup(p_token text) RETURNS TABLE (
id uuid, workspace_id uuid, group_id uuid, license_default text, enabled boolean
) LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
SELECT id, workspace_id, group_id, license_default, enabled
FROM submission_boxes
WHERE public_token = p_token
$$;
-- Rate limiting needs the workspace of an ip-hash without a tenant context.
CREATE FUNCTION submission_rate_count(p_ip_hash text, p_since timestamptz) RETURNS integer
LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
SELECT count(*)::integer FROM submissions
WHERE submitter_ip_hash = p_ip_hash AND created_at > p_since
$$;