-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
--
-- A gated project or collection is invisible to non-members (§22.5), so a user
-- who knows a scope exists can ask to join it: they name a desired role and the
-- request is recorded here, then fanned out to that scope's Owners *across the
-- subtree* (a collection request reaches the collection's Owners, its project's
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
-- the requester is §15-notified of the decision either way.
--
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
-- project or collection a user discovers, not the deployment), and accept grants
-- a scope role rather than minting an RFC invitation.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.

CREATE TABLE IF NOT EXISTS join_requests (
    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
    -- The target scope. 'global' is intentionally excluded: the deployment is
    -- not a thing one "discovers and joins" (§22.8 names a project/collection).
    scope_type          TEXT NOT NULL
                            CHECK (scope_type IN ('project', 'collection')),
    scope_id            TEXT NOT NULL,
    requester_user_id   INTEGER NOT NULL
                            REFERENCES users(id) ON DELETE CASCADE,
    -- The role the requester is asking for ({owner, contributor}, the §22.6
    -- unified vocabulary). The accepting Owner may grant this or a narrower role.
    requested_role      TEXT NOT NULL
                            CHECK (requested_role IN ('owner', 'contributor')),
    -- Optional free text — "who I am / why I want in". Bounded by the API layer.
    message             TEXT,
    status              TEXT NOT NULL DEFAULT 'pending'
                            CHECK (status IN ('pending', 'accepted', 'declined')),
    created_at          TEXT NOT NULL DEFAULT (datetime('now')),
    decided_at          TEXT,
    decided_by_user_id  INTEGER REFERENCES users(id) ON DELETE SET NULL,
    -- The role actually granted on accept (may differ from requested_role if the
    -- Owner narrowed it); NULL until accepted.
    granted_role        TEXT CHECK (granted_role IN ('owner', 'contributor')),
    -- The owner-facing notification row that carries the Accept/Decline action.
    notification_id     INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_join_requests_scope
    ON join_requests(scope_type, scope_id, status);

CREATE INDEX IF NOT EXISTS idx_join_requests_requester
    ON join_requests(requester_user_id, status);

-- At most one open (pending) request per (scope, requester): a second ask while
-- one is still pending is a 409, not a duplicate row. A decided request
-- (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
    ON join_requests(scope_type, scope_id, requester_user_id)
    WHERE status = 'pending';
