initial, copy postgres stuff for migrations, main.go test

This commit is contained in:
2026-09-04 21:11:41 +02:00
parent 506df87f84
commit 090cc0f00d
14 changed files with 863 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
-- 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();