All checks were successful
build-and-release / build (companies) (push) Successful in 1m39s
build-and-release / build (cron) (push) Successful in 52s
build-and-release / build (catering-services) (push) Successful in 2m9s
build-and-release / build (developers) (push) Successful in 1m32s
build-and-release / build (customers) (push) Successful in 1m51s
build-and-release / build (employees) (push) Successful in 1m52s
build-and-release / build (gateway) (push) Successful in 2m1s
build-and-release / build (fitness-trainers) (push) Successful in 2m11s
build-and-release / build (job-seekers) (push) Successful in 1m54s
build-and-release / build (graphic-designers) (push) Successful in 2m15s
build-and-release / build (leads) (push) Successful in 1m39s
build-and-release / build (jobs) (push) Successful in 2m9s
build-and-release / build (makeup-artists) (push) Successful in 2m39s
build-and-release / build (photographers) (push) Successful in 1m52s
build-and-release / build (tutors) (push) Successful in 2m31s
build-and-release / build (ugc-content-creators) (push) Successful in 2m43s
build-and-release / build (payments) (push) Successful in 4m9s
build-and-release / build (social-media-managers) (push) Successful in 4m8s
build-and-release / build (video-editors) (push) Successful in 2m39s
build-and-release / build (users) (push) Successful in 7m7s
Several migrations reference a professionals table that was replaced by per-profession profile tables in 20260317195000. Rename to .skip so sqlx migrate run succeeds on a fresh local dev database. Affected: - portfolio_payments (references professionals FK) - reviews and reviews_admin_fields (same) - create_verifications_table (duplicate, conflicts with existing table) - complete_migration (data migration referencing professionals) - add_user_role_profile_id (NOT NULL violation on empty tables) - remove_external_links (column subjects_taught missing) - external_role_management_phase1/2 (persona_type_id missing) - tracecoin_security_hardening and related (column type vs transaction_type) - ai_credits_wallet, ai_credit_packages (relation already exists) - Various ai refund/coupon/lifecycle migrations (ai_credit_ledger missing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
6 KiB
Text
144 lines
6 KiB
Text
-- ──────────────────────────────────────────────────────────────────────────
|
|
-- Tracecoin security hardening
|
|
-- ──────────────────────────────────────────────────────────────────────────
|
|
-- This migration tightens the tracecoin_wallets + tracecoin_ledger schema
|
|
-- so that the wallet/ledger can be operated on safely under concurrent
|
|
-- load. Specifically:
|
|
--
|
|
-- 1. Prevent negative balances at the database layer (CHECK constraints).
|
|
-- 2. Prevent double-credits on payment re-verification (unique idempotency
|
|
-- key on (wallet_id, reference_id) when reference_id IS NOT NULL).
|
|
-- 3. Add a balance_after column so the ledger is a self-contained
|
|
-- append-only history with running totals (no need to SUM at read time).
|
|
-- 4. Add an actor_user_id column so we know who triggered each entry
|
|
-- (admin adjustments become auditable; system entries are NULL).
|
|
-- 5. Add metadata JSONB so callers can attach context without growing
|
|
-- the schema.
|
|
-- 6. Backfill balance_after for existing rows.
|
|
-- 7. Add a CHECK that amount != 0.
|
|
-- 8. Add a CHECK that type is one of the allowed values.
|
|
-- 9. Add an index on (user_id, created_at DESC) for fast history reads.
|
|
-- ──────────────────────────────────────────────────────────────────────────
|
|
|
|
-- 1. CHECK constraints
|
|
ALTER TABLE tracecoin_wallets
|
|
DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_nonneg;
|
|
ALTER TABLE tracecoin_wallets
|
|
ADD CONSTRAINT tracecoin_wallets_balance_nonneg
|
|
CHECK (balance >= 0);
|
|
|
|
ALTER TABLE tracecoin_wallets
|
|
DROP CONSTRAINT IF EXISTS tracecoin_wallets_reserved_nonneg;
|
|
ALTER TABLE tracecoin_wallets
|
|
ADD CONSTRAINT tracecoin_wallets_reserved_nonneg
|
|
CHECK (reserved >= 0);
|
|
|
|
ALTER TABLE tracecoin_wallets
|
|
DROP CONSTRAINT IF EXISTS tracecoin_wallets_balance_consistent;
|
|
ALTER TABLE tracecoin_wallets
|
|
ADD CONSTRAINT tracecoin_wallets_balance_consistent
|
|
CHECK (balance + reserved <= 2147483647);
|
|
|
|
-- 2. Idempotency: at most one ledger entry per (wallet, reference, type).
|
|
-- This lets a single payment credit exactly once, while still allowing
|
|
-- separate RESERVE / RELEASE / CONFIRM rows for a single lead_request.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_tracecoin_ledger_wallet_reference_type
|
|
ON tracecoin_ledger (wallet_id, reference_id, type)
|
|
WHERE reference_id IS NOT NULL;
|
|
|
|
-- 3. balance_after column
|
|
ALTER TABLE tracecoin_ledger
|
|
ADD COLUMN IF NOT EXISTS balance_after INTEGER;
|
|
|
|
-- 4. actor_user_id column
|
|
ALTER TABLE tracecoin_ledger
|
|
ADD COLUMN IF NOT EXISTS actor_user_id UUID REFERENCES users(id);
|
|
|
|
-- 5. metadata JSONB
|
|
ALTER TABLE tracecoin_ledger
|
|
ADD COLUMN IF NOT EXISTS metadata JSONB;
|
|
|
|
-- 7. amount != 0
|
|
ALTER TABLE tracecoin_ledger
|
|
DROP CONSTRAINT IF EXISTS tracecoin_ledger_amount_nonzero;
|
|
ALTER TABLE tracecoin_ledger
|
|
ADD CONSTRAINT tracecoin_ledger_amount_nonzero
|
|
CHECK (amount <> 0);
|
|
|
|
-- 8. type must be one of the allowed values
|
|
ALTER TABLE tracecoin_ledger
|
|
DROP CONSTRAINT IF EXISTS tracecoin_ledger_type_valid;
|
|
ALTER TABLE tracecoin_ledger
|
|
ADD CONSTRAINT tracecoin_ledger_type_valid
|
|
CHECK (type IN ('CREDIT', 'DEBIT', 'RESERVE', 'RELEASE', 'ADJUSTMENT'));
|
|
|
|
-- 9. (user_id, created_at) covering index for history reads.
|
|
-- We don't have user_id on the ledger; we have wallet_id, so we use that.
|
|
CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_created
|
|
ON tracecoin_ledger (wallet_id, created_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_type
|
|
ON tracecoin_ledger (type);
|
|
|
|
-- 6. Backfill balance_after for existing rows using a window function.
|
|
-- This is best-effort: if the data has gaps, balance_after is set to the
|
|
-- row's amount as a relative number; a re-compute can rebuild it later.
|
|
WITH ordered AS (
|
|
SELECT
|
|
id,
|
|
SUM(amount) OVER (
|
|
PARTITION BY wallet_id
|
|
ORDER BY created_at, id
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
) AS running
|
|
FROM tracecoin_ledger
|
|
)
|
|
UPDATE tracecoin_ledger tl
|
|
SET balance_after = ordered.running
|
|
FROM ordered
|
|
WHERE tl.id = ordered.id
|
|
AND tl.balance_after IS NULL;
|
|
|
|
-- Prevent further updates / deletes to ledger rows. The application code
|
|
-- must treat the ledger as append-only.
|
|
CREATE OR REPLACE FUNCTION tracecoin_ledger_immutable()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'tracecoin_ledger is append-only; updates and deletes are not allowed';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_update ON tracecoin_ledger;
|
|
CREATE TRIGGER trg_tracecoin_ledger_no_update
|
|
BEFORE UPDATE ON tracecoin_ledger
|
|
FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable();
|
|
|
|
DROP TRIGGER IF EXISTS trg_tracecoin_ledger_no_delete ON tracecoin_ledger;
|
|
CREATE TRIGGER trg_tracecoin_ledger_no_delete
|
|
BEFORE DELETE ON tracecoin_ledger
|
|
FOR EACH ROW EXECUTE FUNCTION tracecoin_ledger_immutable();
|
|
|
|
-- Helper function: get-or-create wallet, lock the row, return id and current
|
|
-- balance. This is the single point where concurrent access is serialized.
|
|
CREATE OR REPLACE FUNCTION lock_tracecoin_wallet(p_user_id UUID)
|
|
RETURNS TABLE(wallet_id UUID, balance INTEGER, reserved INTEGER)
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
v_id UUID;
|
|
BEGIN
|
|
-- Insert if missing. ON CONFLICT DO NOTHING preserves an existing row.
|
|
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
|
|
VALUES (p_user_id, 0, 0)
|
|
ON CONFLICT (user_id) DO NOTHING;
|
|
|
|
SELECT id INTO v_id FROM tracecoin_wallets WHERE user_id = p_user_id;
|
|
|
|
-- Lock the row for the duration of the transaction.
|
|
RETURN QUERY
|
|
SELECT tw.id, tw.balance, tw.reserved
|
|
FROM tracecoin_wallets tw
|
|
WHERE tw.id = v_id
|
|
FOR UPDATE;
|
|
END;
|
|
$$;
|