package main import ( "database/sql" "log" "strings" ) func runMigrations(database *sql.DB) { migrations := []string{ `ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`, `ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`, // Verification codes use a generic identifier column. `ALTER TABLE verification_codes ADD COLUMN identifier TEXT NOT NULL DEFAULT ''`, // Indexes for auth tables (created here so they run after column migrations). `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, `CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier)`, // Dedupe RSVPs: keep earliest per (event_id, name). `DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`, // Plus-one tracking for RSVPs. `ALTER TABLE rsvps ADD COLUMN plus_one INTEGER NOT NULL DEFAULT 0`, // Drop legacy phone/email columns from verification_codes. They were // NOT NULL with no default, so inserts using only identifier failed. `DROP INDEX IF EXISTS idx_verification_codes_phone`, `DROP INDEX IF EXISTS idx_verification_codes_email`, `ALTER TABLE verification_codes DROP COLUMN phone`, `ALTER TABLE verification_codes DROP COLUMN email`, // SMS consent logging for Twilio compliance. `CREATE TABLE IF NOT EXISTS sms_consents ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT NOT NULL, ip_address TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, // Attendee cap (0 = uncapped); capped events require login to RSVP. `ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`, } for _, m := range migrations { _, err := database.Exec(m) if err != nil { if strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "no such column") { continue } log.Printf("migration warning: %v", err) } } rebuildLegacyUsersTable(database) } // rebuildLegacyUsersTable migrates users tables from the phone-auth era // (phone NOT NULL, no email column). SQLite can't add a UNIQUE column or // drop NOT NULL via ALTER TABLE, so the table has to be rebuilt. Detected // by the missing email column. func rebuildLegacyUsersTable(database *sql.DB) { var hasEmail int err := database.QueryRow( `SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'email'`, ).Scan(&hasEmail) if err != nil || hasEmail > 0 { return } // Disable FK enforcement so DROP TABLE users doesn't cascade-delete // sessions. main.go sets MaxOpenConns(1), so these pragmas apply to // the same connection the transaction runs on. database.Exec(`PRAGMA foreign_keys=OFF`) defer database.Exec(`PRAGMA foreign_keys=ON`) tx, err := database.Begin() if err != nil { log.Printf("users rebuild: %v", err) return } defer tx.Rollback() stmts := []string{ `CREATE TABLE users_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT UNIQUE, email TEXT UNIQUE, name TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `INSERT INTO users_new (id, phone, name, created_at) SELECT id, phone, name, created_at FROM users`, `DROP TABLE users`, `ALTER TABLE users_new RENAME TO users`, } for _, stmt := range stmts { if _, err := tx.Exec(stmt); err != nil { log.Printf("users rebuild: %v", err) return } } if err := tx.Commit(); err != nil { log.Printf("users rebuild: %v", err) } }