Schema: purge ALTERs per pre-launch recreate policy; nullable account_id for unredeemed gift-card expiries

The project is pre-launch with no production DB — all dev starts from a fresh
volume recreated from init-scripts/init-script.sql. All ALTER statements are
removed and their effects expressed directly in the CREATE statements:
name_history now lives after bookings with its booking_id FK inlined (no
ALTER TABLE ADD CONSTRAINT), the critical_payment_log enum value is in the
CREATE TYPE (no ALTER TYPE ADD VALUE), and voucher_type_at_purchase is in the
CREATE TABLE gift_cards (no ADD COLUMN).

gift_card_expired_balances.account_id is now nullable: CleanupExpiredGiftCards
inserts NULL for unredeemed gift cards (bought for non-account-holders, never
claimed) — a NOT NULL column made that expiry fail at runtime. The three tests
that papered over this with ALTER TABLE DROP NOT NULL now rely on the schema
directly.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 3f6250ea81
commit d9c2c5ac2c
2 changed files with 29 additions and 56 deletions
@@ -2688,16 +2688,12 @@ func TestCleanupExpiredGiftCards(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// account_id is nullable in the schema — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create expired gift card (unused for 25 months)
var expiredCardID string
err = tx.QueryRow(ctx, `
err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at)
VALUES (100.00, 50.00, NOW() - INTERVAL '25 months')
RETURNING id
@@ -2759,16 +2755,9 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create recently used gift card
var recentCardID string
err = tx.QueryRow(ctx, `
err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at)
VALUES (100.00, 75.00, NOW())
RETURNING id
@@ -2810,14 +2799,6 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference). Even though this
// card is redeemed, the function may also match other cards; ensure schema allows it.
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create a user to be the redeemer
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
+26 -34
View File
@@ -255,26 +255,6 @@ CREATE INDEX IF NOT EXISTS idx_users_n_last_name_trgm ON users USING GIN (n_last
-- Guest accounts can share emails; registered accounts cannot
CREATE UNIQUE INDEX idx_users_email_registered ON users (email) WHERE account_role != 'guest';
-- =======================================
-- NAME HISTORY TABLE
-- =======================================
-- WHY: Tracks user-initiated first/last name changes for display in booking modals
-- and GDPR export. History is used to show "(formerly [old first] [old last])"
-- on completed booking receipts and admin views.
CREATE TABLE name_history (
id CHAR(12) PRIMARY KEY DEFAULT generate_name_history_id(),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
previous_first_name VARCHAR(50) NOT NULL,
previous_last_name VARCHAR(50) NOT NULL,
booking_id CHAR(12),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_name_history_user_id ON name_history(user_id);
CREATE INDEX idx_name_history_changed_at ON name_history(changed_at);
CREATE INDEX idx_name_history_user_active ON name_history(user_id) WHERE booking_id IS NULL;
-- =======================================
-- VERIFICATION CODES TABLE
-- =======================================
@@ -395,8 +375,26 @@ CREATE INDEX idx_bookings_status_end_time ON bookings(status, end_time);
CREATE INDEX IF NOT EXISTS idx_bookings_notes_trgm ON bookings USING GIN (notes gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_bookings_start_time ON bookings(start_time);
ALTER TABLE name_history ADD CONSTRAINT fk_name_history_booking_id
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL;
-- =======================================
-- NAME HISTORY TABLE
-- =======================================
-- WHY: Tracks user-initiated first/last name changes for display in booking modals
-- and GDPR export. History is used to show "(formerly [old first] [old last])"
-- on completed booking receipts and admin views.
-- NOTE: booking_id FK references bookings, so this table is created after bookings.
CREATE TABLE name_history (
id CHAR(12) PRIMARY KEY DEFAULT generate_name_history_id(),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
previous_first_name VARCHAR(50) NOT NULL,
previous_last_name VARCHAR(50) NOT NULL,
booking_id CHAR(12) CONSTRAINT fk_name_history_booking_id REFERENCES bookings(id) ON DELETE SET NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_name_history_user_id ON name_history(user_id);
CREATE INDEX idx_name_history_changed_at ON name_history(changed_at);
CREATE INDEX idx_name_history_user_active ON name_history(user_id) WHERE booking_id IS NULL;
-- =======================================
-- BOOKING SERVICES JUNCTION TABLE
@@ -842,15 +840,8 @@ INSERT INTO business_settings (
-- 'critical_payment_log' surfaces unresolved money events (stale pending
-- payments/till sales, refunds at the retry cap) in the admin notification
-- centre — the DB-backed stand-in for the un-watched CRITICAL payment logs.
-- Fresh installs get it from the CREATE TYPE below; existing deployments must
-- apply the ALTER TYPE after it (NOTE: ALTER TYPE ... ADD VALUE cannot run
-- inside a transaction block — run on a connection with autocommit). The
-- value is a no-op here on fresh installs (it is already in the CREATE TYPE).
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed', 'refund_failed', 'critical_payment_log');
-- Existing-deployment migration for the value added to the CREATE TYPE above.
ALTER TYPE admin_notification_reason ADD VALUE IF NOT EXISTS 'critical_payment_log';
CREATE TABLE admin_notifications (
id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(),
reason admin_notification_reason NOT NULL,
@@ -2244,13 +2235,16 @@ CREATE INDEX idx_gift_card_transactions_type ON gift_card_transactions(transacti
-- before transfer to reclaim fund). No claim deadline imposed — consumer can recover
-- at any time with valid account ID.
--
-- No PII stored: only account ID + balance amount. After account anonymization,
-- we cannot verify claims without the account ID (by design — GDPR compliance).
-- No PII stored: only account ID (nullable — unredeemed gift cards have no
-- account) + balance amount. Account-anonymized claims are verified with the
-- account ID; unredeemed-card expiries carry NULL (no account to reference).
-- =======================================
CREATE TABLE gift_card_expired_balances (
id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_expired_balances_id(),
account_id CHAR(12) NOT NULL,
-- NULL for unredeemed gift-card expiries (no user account to reference);
-- account-based expiries always store the account ID.
account_id CHAR(12),
original_balance NUMERIC(10,2) NOT NULL,
expired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_at TIMESTAMPTZ,
@@ -2517,5 +2511,3 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);
-- Migration: add voucher_type_at_purchase to gift_cards (for SPV/MPV per-card tracking)
ALTER TABLE gift_cards ADD COLUMN IF NOT EXISTS voucher_type_at_purchase VARCHAR(3);