-- core: Discord-linked identity and callsign verification. -- --------------------------------------------------------------------------- -- users -- --------------------------------------------------------------------------- CREATE TABLE core.users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- Discord snowflakes are unsigned 64-bit, but the timestamp component keeps -- real values under 2^63 until roughly 2084, so bigint is safe and indexes -- far better than text. discord_id bigint NOT NULL UNIQUE, -- NULL until the user claims one. Verification is a separate step: a claim -- is not a verification, and roles must key off verified_at, not this. callsign core.callsign, callsign_verified_at timestamptz, verification_method text CHECK (verification_method IN ('arrl_email', 'qrz_profile', 'admin', 'lotw')), grid core.gridsquare, timezone text, -- IANA name, e.g. 'Europe/Ljubljana' created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- A verified callsign must record how and when. CONSTRAINT users_verification_complete CHECK ( (callsign_verified_at IS NULL AND verification_method IS NULL) OR (callsign_verified_at IS NOT NULL AND verification_method IS NOT NULL AND callsign IS NOT NULL) ) ); COMMENT ON TABLE core.users IS 'One row per Discord user who has interacted with a stateful command. Deliberately no soft-delete column: a deletion request must actually remove the row, so there is nothing left to resurrect.'; -- Two users may not both hold a VERIFIED claim on the same callsign. Unverified -- claims are unconstrained, since anyone can type anything. CREATE UNIQUE INDEX users_verified_callsign_uniq ON core.users (callsign) WHERE callsign_verified_at IS NOT NULL; CREATE INDEX users_callsign_idx ON core.users (callsign) WHERE callsign IS NOT NULL; CREATE TRIGGER users_touch BEFORE UPDATE ON core.users FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at(); -- --------------------------------------------------------------------------- -- verifications -- --------------------------------------------------------------------------- CREATE TABLE core.verifications ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES core.users(id) ON DELETE CASCADE, method text NOT NULL CHECK (method IN ('arrl_email', 'qrz_profile', 'admin', 'lotw')), callsign core.callsign NOT NULL, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'succeeded', 'failed', 'expired', 'cancelled')), -- SHA-256 of the challenge token, never the token. Same reasoning as API -- keys: a database dump must not let anyone complete a verification. challenge_hash bytea, -- Diagnostics only. Must NOT contain the email address or any other -- identifier - the whole point of hashing the challenge is undone if the -- address is sitting in a jsonb column next to it. detail jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, completed_at timestamptz ); CREATE INDEX verifications_user_idx ON core.verifications (user_id, created_at DESC); CREATE INDEX verifications_pending_idx ON core.verifications (expires_at) WHERE status = 'pending'; -- --------------------------------------------------------------------------- -- subscriptions -- --------------------------------------------------------------------------- -- Discord-shaped, but persistent and worth backing up, so it belongs here -- rather than in Redis. CREATE TABLE core.subscriptions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, guild_id bigint, -- NULL for a DM subscription channel_id bigint NOT NULL, user_id bigint REFERENCES core.users(id) ON DELETE CASCADE, kind text NOT NULL CHECK (kind IN ('pota', 'sota', 'wwff', 'cluster', 'rbn', 'propagation', 'net')), -- Band, mode, region, distance, specific refs. Open-ended by nature, and -- the filter shape differs per kind, so jsonb rather than twenty columns -- that are NULL for most rows. filters jsonb NOT NULL DEFAULT '{}'::jsonb, enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX subscriptions_channel_idx ON core.subscriptions (channel_id) WHERE enabled; CREATE INDEX subscriptions_kind_idx ON core.subscriptions (kind) WHERE enabled; CREATE INDEX subscriptions_filters_idx ON core.subscriptions USING gin (filters); -- Postgres does not index the referencing side of a foreign key. Without this, -- deleting a user sequentially scans subscriptions to enforce the cascade. CREATE INDEX subscriptions_user_idx ON core.subscriptions (user_id) WHERE user_id IS NOT NULL; CREATE TRIGGER subscriptions_touch BEFORE UPDATE ON core.subscriptions FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();