122 lines
5.2 KiB
SQL
122 lines
5.2 KiB
SQL
-- 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();
|