-- ============================================================================= -- 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;