invy/backend/migrate_production.sql

389 lines
14 KiB
PL/PgSQL

-- =============================================================================
-- INVY — Production Migration Script
-- =============================================================================
-- SAFE: Additive-only. Nothing is dropped. All blocks are idempotent.
-- Run once to bring a production DB (old schema) in sync with the new schema.
--
-- Order of execution:
-- 1. Enable extensions
-- 2. Create new tables (IF NOT EXISTS)
-- 3. Patch existing tables (ADD COLUMN IF NOT EXISTS / ALTER/ADD CONSTRAINT)
-- 4. Migrate old `guests` rows → `guests_v2` (only when guests_v2 is empty)
-- 5. Add indexes and triggers (IF NOT EXISTS)
-- =============================================================================
-- =============================================================================
-- STEP 1 — Enable UUID extension
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- =============================================================================
-- STEP 2a — Create `users` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- =============================================================================
-- STEP 2b — Create `events` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
date TIMESTAMP WITH TIME ZONE,
location TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
-- =============================================================================
-- STEP 2c — Create `event_members` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS event_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'editor', 'viewer')),
display_name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_event_members_event_id ON event_members(event_id);
CREATE INDEX IF NOT EXISTS idx_event_members_user_id ON event_members(user_id);
CREATE INDEX IF NOT EXISTS idx_event_members_event_user ON event_members(event_id, user_id);
-- =============================================================================
-- STEP 2d — Create `guests_v2` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS guests_v2 (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
added_by_user_id UUID NOT NULL REFERENCES users(id),
-- identity
first_name TEXT NOT NULL,
last_name TEXT NOT NULL DEFAULT '',
email TEXT,
phone TEXT, -- legacy alias
phone_number TEXT,
-- RSVP
rsvp_status TEXT NOT NULL DEFAULT 'invited'
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined')),
meal_preference TEXT,
-- plus-one
has_plus_one BOOLEAN DEFAULT FALSE,
plus_one_name TEXT,
-- seating
table_number TEXT,
side TEXT, -- e.g. "groom", "bride"
-- provenance
owner_email TEXT,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('google', 'manual', 'self-service')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_id ON guests_v2(event_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_added_by ON guests_v2(added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_user ON guests_v2(event_id, added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_phone_number ON guests_v2(phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_phone ON guests_v2(event_id, phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_status ON guests_v2(event_id, rsvp_status);
CREATE INDEX IF NOT EXISTS idx_guests_v2_owner_email ON guests_v2(event_id, owner_email);
CREATE INDEX IF NOT EXISTS idx_guests_v2_source ON guests_v2(event_id, source);
-- =============================================================================
-- STEP 2e — Create `rsvp_tokens` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS rsvp_tokens (
token TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
-- =============================================================================
-- STEP 3a — Patch `events` table: add WhatsApp / RSVP columns (IF NOT EXISTS)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner1_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner2_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN venue TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN event_time TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN guest_link TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
-- =============================================================================
-- STEP 3b — Patch `guests_v2`: add any missing columns (forward-compat)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone_number TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN last_name TEXT NOT NULL DEFAULT '';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN notes TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN side TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN owner_email TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
-- Fix rsvp_status constraint: old versions used 'status' column name or enum
DO $$
BEGIN
-- rename `status` → `rsvp_status` if that old column exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'status'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'rsvp_status'
) THEN
ALTER TABLE guests_v2 RENAME COLUMN status TO rsvp_status;
END IF;
END $$;
-- Ensure CHECK constraint is present (safe drop+add)
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_rsvp_status_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_rsvp_status_check
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_source_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_source_check
CHECK (source IN ('google', 'manual', 'self-service'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- =============================================================================
-- STEP 3c — updated_at triggers
-- =============================================================================
CREATE OR REPLACE FUNCTION _update_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;
DO $$
BEGIN
CREATE TRIGGER trg_guests_v2_updated_at
BEFORE UPDATE ON guests_v2
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TRIGGER trg_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- STEP 4 — Migrate old `guests` rows → `guests_v2`
--
-- Conditions:
-- • The old `guests` table must exist.
-- • guests_v2 must be EMPTY (idempotent guard — never runs twice).
--
-- Strategy:
-- • For each distinct `owner` in the old table create a row in `users`.
-- • Create one migration event ("Migrated Wedding") owned by the first user.
-- • Insert event_members for every owner → that event (role = admin).
-- • Insert guests mapping:
-- rsvp_status: 'pending' → 'invited', 'accepted' → 'confirmed', else as-is
-- phone_number field → phone_number + phone columns
-- owner → owner_email
-- source = 'google' (they came from Google import originally)
-- =============================================================================
DO $$
DECLARE
old_table_exists BOOLEAN;
new_table_empty BOOLEAN;
migration_event_id UUID;
default_user_id UUID;
owner_row RECORD;
owner_user_id UUID;
BEGIN
-- Check preconditions
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'guests' AND table_schema = 'public'
) INTO old_table_exists;
SELECT (COUNT(*) = 0) FROM guests_v2 INTO new_table_empty;
IF NOT old_table_exists OR NOT new_table_empty THEN
RAISE NOTICE 'Migration skipped: old_table_exists=%, new_table_empty=%',
old_table_exists, new_table_empty;
RETURN;
END IF;
RAISE NOTICE 'Starting data migration from guests → guests_v2 …';
-- ── Create one user per distinct owner ──────────────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
INSERT INTO users (email)
VALUES (owner_row.email)
ON CONFLICT (email) DO NOTHING;
END LOOP;
-- ── Pick (or create) the migration event ────────────────────────────────
SELECT id INTO migration_event_id FROM events LIMIT 1;
IF migration_event_id IS NULL THEN
INSERT INTO events (name, date, location)
VALUES ('Migrated Wedding', CURRENT_TIMESTAMP, 'Imported from previous system')
RETURNING id INTO migration_event_id;
END IF;
-- ── Get a fallback user (the first one alphabetically) ──────────────────
SELECT id INTO default_user_id FROM users ORDER BY email LIMIT 1;
-- ── Create event_members entries for each owner ──────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
SELECT id INTO owner_user_id FROM users WHERE email = owner_row.email;
INSERT INTO event_members (event_id, user_id, role)
VALUES (migration_event_id, owner_user_id, 'admin')
ON CONFLICT (event_id, user_id) DO NOTHING;
END LOOP;
-- ── Copy guests ──────────────────────────────────────────────────────────
INSERT INTO guests_v2 (
event_id,
added_by_user_id,
first_name,
last_name,
email,
phone_number,
phone,
rsvp_status,
meal_preference,
has_plus_one,
plus_one_name,
table_number,
owner_email,
source,
notes,
created_at
)
SELECT
migration_event_id,
COALESCE(
(SELECT id FROM users WHERE email = NULLIF(TRIM(g.owner), '')),
default_user_id
),
g.first_name,
COALESCE(g.last_name, ''),
g.email,
g.phone_number,
g.phone_number,
CASE g.rsvp_status
WHEN 'accepted' THEN 'confirmed'
WHEN 'pending' THEN 'invited'
WHEN 'declined' THEN 'declined'
ELSE 'invited'
END,
g.meal_preference,
COALESCE(g.has_plus_one, FALSE),
g.plus_one_name,
g.table_number::TEXT,
NULLIF(TRIM(COALESCE(g.owner, '')), ''),
'google',
g.notes,
COALESCE(g.created_at, CURRENT_TIMESTAMP)
FROM guests g;
RAISE NOTICE 'Migration complete. Rows inserted: %', (SELECT COUNT(*) FROM guests_v2);
END $$;
-- =============================================================================
-- DONE
-- =============================================================================
SELECT
(SELECT COUNT(*) FROM users) AS users_total,
(SELECT COUNT(*) FROM events) AS events_total,
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_total;