Files
simplex-chat/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs
T
2026-09-25 18:22:34 +00:00

148 lines
5.2 KiB
Haskell

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations, servicePrefix, withPrefix) where
import Data.List (sortOn)
import Data.Text (Text)
import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges (badgeSchema, badgeSchemaDown, withPrefix)
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
import Text.RawString.QQ (r)
badgeServiceSchemaMigrations :: [Migration]
badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations
where
migration (name, up, down) = Migration {name, up, down}
schemaMigrations :: [(String, Text, Maybe Text)]
schemaMigrations =
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema),
("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops)
]
-- | The client tables share this database, so the service tables are the same names behind a prefix.
servicePrefix :: Text
servicePrefix = "sx_badge_service_"
m20260915_badge_service_schema :: Text
m20260915_badge_service_schema =
badgeSchema servicePrefix
<> withPrefix
servicePrefix
-- The payment columns are added to @payments, which badgeSchema owns. crypto_paid,
-- crypto_due and paid_in_full record the provider's own figures: it applies a payment
-- tolerance and adds a network fee after a partial payment, so what is owed and whether
-- an invoice is settled are its verdicts, not amounts recomputable from what we store.
[r|
ALTER TABLE @payments ADD COLUMN crypto_paid TEXT;
ALTER TABLE @payments ADD COLUMN crypto_due TEXT;
ALTER TABLE @payments ADD COLUMN paid_in_full SMALLINT NOT NULL DEFAULT 0;
CREATE TABLE @badge_codes(
badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code_hash BYTEA NOT NULL,
badge_type TEXT NOT NULL,
months SMALLINT NOT NULL,
code_payment_status TEXT NOT NULL,
redeemed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(code_hash)
);
ALTER TABLE @badge_purchases ADD COLUMN badge_code_id BIGINT REFERENCES @badge_codes;
CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id);
CREATE TABLE @badge_code_invoices(
invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE,
badge_code_id BIGINT NOT NULL REFERENCES @badge_codes,
price_id TEXT NOT NULL REFERENCES @badge_prices,
offer_id TEXT REFERENCES @badge_offers,
provider_ref TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id);
CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id);
-- provider_ref is the provider's own id for the invoice; unique so a webhook or poller read
-- resolves a payment to exactly one invoice.
CREATE UNIQUE INDEX @idx_badge_code_invoices_provider_ref ON @badge_code_invoices(provider_ref);
|]
-- Two filters run on every poller pass and neither may read the whole table, or the pass
-- lengthens for as long as the service keeps selling: the expiry sweep, on (status, expires_at),
-- and the read lane, which takes a window of created_at. Status leads the first because it is
-- matched by equality there; the second matches it with <>, which no index can seek, so it seeks
-- the window and filters what little that leaves.
<> withPrefix
servicePrefix
[r|
CREATE INDEX @idx_invoices_status_expires_at ON @invoices(status, expires_at);
CREATE INDEX @idx_invoices_created ON @invoices(created_at);
|]
down_m20260915_badge_service_schema :: Text
down_m20260915_badge_service_schema =
withPrefix
servicePrefix
[r|
DROP INDEX @idx_invoices_created;
DROP INDEX @idx_invoices_status_expires_at;
DROP INDEX @idx_badge_code_invoices_provider_ref;
DROP INDEX @idx_badge_code_invoices_offer;
DROP INDEX @idx_badge_code_invoices_price;
DROP TABLE @badge_code_invoices;
DROP INDEX @idx_badge_purchases_code;
|]
<> badgeSchemaDown servicePrefix
<> withPrefix
servicePrefix
[r|
DROP TABLE @badge_codes;
|]
m20260918_badge_group_ops :: Text
m20260918_badge_group_ops =
withPrefix
servicePrefix
[r|
ALTER TABLE @badge_codes ADD COLUMN redeem_limit INTEGER NOT NULL DEFAULT 1;
ALTER TABLE @badge_codes ADD COLUMN redeem_count INTEGER NOT NULL DEFAULT 0;
-- Redemptions made before this migration must count against the new limit, or every code
-- redeemed already would read as unspent and could be redeemed once more.
UPDATE @badge_codes SET redeem_count = 1 WHERE redeemed_at IS NOT NULL;
DROP INDEX @idx_badge_purchases_code;
CREATE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id);
|]
-- The index stays non-unique, since a multi-use code may already have several purchases.
down_m20260918_badge_group_ops :: Text
down_m20260918_badge_group_ops =
withPrefix
servicePrefix
[r|
ALTER TABLE @badge_codes DROP COLUMN redeem_count;
ALTER TABLE @badge_codes DROP COLUMN redeem_limit;
|]
{- TODO [badges] deferred with the draft in M20260915_user_badges, service only.
ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA;
-- down
ALTER TABLE @payments DROP COLUMN receipt_hash;
-}