diff --git a/backend/main.py b/backend/main.py index 790f631..0e08096 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,18 +1,24 @@ -from fastapi import FastAPI, Depends, HTTPException, Query, Request +from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, HTMLResponse from sqlalchemy.orm import Session from sqlalchemy import or_ import uvicorn from typing import List, Optional -from uuid import UUID +from uuid import UUID, uuid4 import os +import io +import csv +import json import secrets +import logging from dotenv import load_dotenv import httpx from urllib.parse import urlencode, quote from datetime import timezone, timedelta +logger = logging.getLogger(__name__) + import models import schemas import crud @@ -1463,5 +1469,331 @@ def rsvp_submit( ) +# ============================================ +# Contact Import Endpoint +# POST /admin/import/contacts?event_id=&dry_run=false +# Accepts: multipart/form-data with file field "file" (CSV or JSON) +# ============================================ + +def _normalize_phone(raw: str) -> str: + """Normalize a phone number to E.164 (+972…) format, best-effort.""" + if not raw: + return raw + try: + from whatsapp import WhatsAppService as _WAS + return _WAS.normalize_phone_to_e164(raw) or raw.strip() + except Exception: + return raw.strip() + + +def _parse_csv_rows(content: bytes) -> list[dict]: + """Parse a CSV file and return a list of dicts (header row as keys).""" + text = content.decode("utf-8-sig", errors="replace") # handle BOM + reader = csv.DictReader(io.StringIO(text)) + return [dict(row) for row in reader] + + +def _parse_json_rows(content: bytes) -> list[dict]: + """Parse a JSON file — supports array at root OR {data: [...]}.""" + payload = json.loads(content.decode("utf-8-sig", errors="replace")) + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("data", "contacts", "guests", "rows"): + if key in payload and isinstance(payload[key], list): + return payload[key] + raise ValueError("JSON must be an array or an object with a list field.") + + +# Case-insensitive header normalization map +_FIELD_ALIASES: dict[str, str] = { + # first_name + "first name": "first_name", "firstname": "first_name", "שם פרטי": "first_name", + # last_name + "last name": "last_name", "lastname": "last_name", "שם משפחה": "last_name", + # full_name + "full name": "full_name", "fullname": "full_name", "name": "full_name", "שם מלא": "full_name", + # phone + "phone": "phone", "phone number": "phone", "mobile": "phone", + "טלפון": "phone", "נייד": "phone", "phone_number": "phone", + # email + "email": "email", "email address": "email", "אימייל": "email", + # rsvp status + "rsvp": "rsvp_status", "rsvp status": "rsvp_status", "status": "rsvp_status", + "סטטוס": "rsvp_status", + # meal + "meal": "meal_preference", "meal preference": "meal_preference", + "meal_preference": "meal_preference", "העדפת ארוחה": "meal_preference", + # notes + "notes": "notes", "הערות": "notes", + # side + "side": "side", "צד": "side", + # table + "table": "table_number", "table number": "table_number", "שולחן": "table_number", + # has_plus_one + "plus one": "has_plus_one", "has plus one": "has_plus_one", + "has_plus_one": "has_plus_one", + # plus_one_name + "plus one name": "plus_one_name", "plus_one_name": "plus_one_name", +} + + +def _normalize_row(raw: dict) -> dict: + """Normalise column headers to canonical field names.""" + out = {} + for k, v in raw.items(): + canonical = _FIELD_ALIASES.get(k.strip().lower(), k.strip().lower()) + out[canonical] = v.strip() if isinstance(v, str) else v + return out + + +def _split_full_name(full: str) -> tuple[str, str]: + parts = full.strip().split(None, 1) + if len(parts) == 2: + return parts[0], parts[1] + return parts[0], "" if parts else ("", "") + + +def _coerce_bool(val) -> bool: + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in ("1", "yes", "true", "כן") + return bool(val) + + +@app.post("/admin/import/contacts", response_model=schemas.ImportContactsResponse) +async def import_contacts( + event_id: UUID = Query(..., description="Target event UUID"), + dry_run: bool = Query(False, description="If true, validate and preview but do not write"), + file: UploadFile = File(..., description="CSV or JSON file"), + db: Session = Depends(get_db), + current_user_id=Depends(get_current_user_id), +): + """ + Import contacts from a CSV or JSON file into an event's guest list. + + • Idempotent: if a guest with the same phone_number already exists in this + event, their *missing* fields are filled in — existing data is never + overwritten unless the existing value is blank. + • dry_run=true: returns the preview without touching the database. + • source is always set to 'google' for imported rows. + """ + # ── Auth / access check ────────────────────────────────────────────────── + if not current_user_id: + raise HTTPException(status_code=401, detail="Authentication required.") + + event = db.query(models.Event).filter(models.Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found.") + + # ── Read and parse the uploaded file ──────────────────────────────────── + content = await file.read() + filename = (file.filename or "").lower() + try: + if filename.endswith(".json"): + raw_rows = _parse_json_rows(content) + elif filename.endswith(".csv") or filename.endswith(".xlsx"): + # For XLSX export from our own app, treat as CSV (xlsx export from + # GuestList produces proper column headers in English) + raw_rows = _parse_csv_rows(content) + else: + # Sniff: try JSON then CSV + try: + raw_rows = _parse_json_rows(content) + except Exception: + raw_rows = _parse_csv_rows(content) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}") + + if not raw_rows: + raise HTTPException(status_code=422, detail="File is empty or could not be parsed.") + + # ── Resolve or create the user record for added_by_user_id ────────────── + # current_user_id can be a UUID object, a UUID string, or a plain string (email/admin-user) + user = None + try: + _uid = UUID(str(current_user_id)) + user = db.query(models.User).filter(models.User.id == _uid).first() + except (ValueError, AttributeError): + pass + if user is None: + user = db.query(models.User).filter( + models.User.email == str(current_user_id) + ).first() + if user is None: + # Create a stub user (can happen if logged in via Google but no DB row yet) + user = models.User(email=str(current_user_id)) + if not dry_run: + db.add(user) + db.flush() + + # ── Process rows ───────────────────────────────────────────────────────── + results: list[schemas.ImportRowResult] = [] + counters = {"created": 0, "updated": 0, "skipped": 0, "errors": 0} + + for idx, raw in enumerate(raw_rows, start=1): + row = _normalize_row(raw) + + # Resolve name + first = row.get("first_name") or "" + last = row.get("last_name") or "" + if not first and row.get("full_name"): + first, last = _split_full_name(row["full_name"]) + + if not first: + counters["skipped"] += 1 + results.append(schemas.ImportRowResult( + row=idx, action="skipped", reason="No name found" + )) + continue + + # Resolve phone + raw_phone = row.get("phone") or row.get("phone_number") or "" + phone = _normalize_phone(raw_phone) if raw_phone else None + + email = row.get("email") or None + + # Must have at least phone or email to identify the guest + if not phone and not email: + counters["skipped"] += 1 + results.append(schemas.ImportRowResult( + row=idx, action="skipped", name=f"{first} {last}".strip(), + reason="No phone or email — cannot identify guest" + )) + continue + + # ── Idempotent lookup ────────────────────────────────────────────── + existing = None + if phone: + existing = db.query(models.Guest).filter( + models.Guest.event_id == event_id, + or_( + models.Guest.phone_number == phone, + models.Guest.phone == phone, + models.Guest.phone_number == raw_phone, + models.Guest.phone == raw_phone, + ), + ).first() + if existing is None and email: + existing = db.query(models.Guest).filter( + models.Guest.event_id == event_id, + models.Guest.email == email, + ).first() + + # ── Map optional fields ──────────────────────────────────────────── + rsvp_raw = (row.get("rsvp_status") or "").lower() + rsvp_map = {"accepted": "confirmed", "pending": "invited", "yes": "confirmed", + "no": "declined", "כן": "confirmed", "לא": "declined"} + rsvp = rsvp_map.get(rsvp_raw, rsvp_raw) if rsvp_raw else None + if rsvp and rsvp not in ("invited", "confirmed", "declined"): + rsvp = "invited" + + has_plus = _coerce_bool(row["has_plus_one"]) if "has_plus_one" in row else None + + if existing: + # Update only blank fields — never overwrite existing data + changed = False + if not existing.first_name and first: + existing.first_name = first; changed = True + if not existing.last_name and last: + existing.last_name = last; changed = True + if not existing.email and email: + existing.email = email; changed = True + if not existing.phone_number and phone: + existing.phone_number = phone; existing.phone = phone; changed = True + if not existing.meal_preference and row.get("meal_preference"): + existing.meal_preference = row["meal_preference"]; changed = True + if not existing.notes and row.get("notes"): + existing.notes = row["notes"]; changed = True + if not existing.side and row.get("side"): + existing.side = row["side"]; changed = True + if not existing.table_number and row.get("table_number"): + existing.table_number = row["table_number"]; changed = True + if has_plus is not None and not existing.has_plus_one: + existing.has_plus_one = has_plus; changed = True + if not existing.plus_one_name and row.get("plus_one_name"): + existing.plus_one_name = row["plus_one_name"]; changed = True + if rsvp and existing.rsvp_status in (None, "invited", models.GuestStatus.invited): + existing.rsvp_status = rsvp; changed = True + + if changed: + counters["updated"] += 1 + action = "updated" + else: + counters["skipped"] += 1 + action = "skipped" + + results.append(schemas.ImportRowResult( + row=idx, action=action, + name=f"{existing.first_name} {existing.last_name}".strip(), + phone=existing.phone_number, + )) + else: + # Create new guest + # On dry_run user.id may be None (not flushed); use a placeholder UUID + _added_by = user.id + if _added_by is None: + try: + _added_by = UUID(str(current_user_id)) + except ValueError: + _added_by = uuid4() # unreachable in prod, safety net + new_guest = models.Guest( + event_id=event_id, + added_by_user_id=_added_by, + first_name=first, + last_name=last, + email=email, + phone_number=phone, + phone=phone, + rsvp_status=rsvp or models.GuestStatus.invited, + meal_preference=row.get("meal_preference") or None, + has_plus_one=has_plus or False, + plus_one_name=row.get("plus_one_name") or None, + table_number=row.get("table_number") or None, + side=row.get("side") or None, + notes=row.get("notes") or None, + owner_email=str(current_user_id), + source="google", + ) + if not dry_run: + db.add(new_guest) + counters["created"] += 1 + results.append(schemas.ImportRowResult( + row=idx, action="created" if not dry_run else "would_create", + name=f"{first} {last}".strip(), + phone=phone, + )) + + # ── Commit or rollback ──────────────────────────────────────────────── + if dry_run: + db.rollback() + else: + try: + db.commit() + except Exception as exc: + db.rollback() + logger.error("Import commit failed: %s", exc) + raise HTTPException(status_code=500, detail=f"Database error: {exc}") + + logger.info( + "Import %s event=%s total=%d created=%d updated=%d skipped=%d errors=%d", + "[dry-run]" if dry_run else "[committed]", + event_id, len(raw_rows), + counters["created"], counters["updated"], + counters["skipped"], counters["errors"], + ) + + return schemas.ImportContactsResponse( + dry_run=dry_run, + total=len(raw_rows), + created=counters["created"], + updated=counters["updated"], + skipped=counters["skipped"], + errors=counters["errors"], + rows=results, + ) + + if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/migrate_production.sql b/backend/migrate_production.sql new file mode 100644 index 0000000..3f764df --- /dev/null +++ b/backend/migrate_production.sql @@ -0,0 +1,388 @@ +-- ============================================================================= +-- 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; diff --git a/backend/requirements.txt b/backend/requirements.txt index c2605cb..0681831 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ psycopg2-binary>=2.9.9 pydantic[email]>=2.5.0 httpx>=0.25.2 python-dotenv>=1.0.0 +python-multipart>=0.0.7 diff --git a/backend/run_production_migration.py b/backend/run_production_migration.py new file mode 100644 index 0000000..2080ad5 --- /dev/null +++ b/backend/run_production_migration.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +run_production_migration.py +───────────────────────────────────────────────────────────────────────────── +Execute migrate_production.sql against the configured DATABASE_URL. + +Usage +───── + python run_production_migration.py # normal run + python run_production_migration.py --dry-run # parse SQL but do NOT commit + +Environment variables read from .env (or already in shell): + DATABASE_URL postgresql://user:pass@host:port/dbname + +Exit codes: + 0 success + 1 error +""" + +import argparse +import os +import sys +from pathlib import Path + +import psycopg2 +from dotenv import load_dotenv + +MIGRATION_FILE = Path(__file__).parent / "migrate_production.sql" + + +def parse_args(): + p = argparse.ArgumentParser(description="Run Invy production migration") + p.add_argument( + "--dry-run", + action="store_true", + help="Parse and validate the SQL but roll back instead of committing.", + ) + return p.parse_args() + + +def main(): + args = parse_args() + load_dotenv() + + db_url = os.getenv( + "DATABASE_URL", + "postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests", + ) + + if not MIGRATION_FILE.exists(): + print(f"❌ Migration file not found: {MIGRATION_FILE}") + sys.exit(1) + + sql = MIGRATION_FILE.read_text(encoding="utf-8") + + print(f"{'[DRY-RUN] ' if args.dry_run else ''}Connecting to database …") + try: + conn = psycopg2.connect(db_url) + except Exception as exc: + print(f"❌ Cannot connect: {exc}") + sys.exit(1) + + conn.autocommit = False + cursor = conn.cursor() + + # Capture NOTICE messages from PL/pgSQL RAISE NOTICE + import warnings + conn.notices = [] + + def _notice_handler(diag): + msg = diag.message_primary or str(diag) + conn.notices.append(msg) + print(f" [DB] {msg}") + + conn.add_notice_handler(_notice_handler) + + try: + print("Running migration …") + cursor.execute(sql) + + # Print the summary SELECT result + try: + row = cursor.fetchone() + if row: + print( + f"\n📊 Summary after migration:\n" + f" users : {row[0]}\n" + f" events : {row[1]}\n" + f" guests_v2 : {row[2]}\n" + ) + except Exception: + pass + + if args.dry_run: + conn.rollback() + print("✅ Dry-run complete — rolled back (no changes written).") + else: + conn.commit() + print("✅ Migration committed successfully.") + + except Exception as exc: + conn.rollback() + print(f"\n❌ Migration failed — rolled back.\n Error: {exc}") + sys.exit(1) + finally: + cursor.close() + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/schemas.py b/backend/schemas.py index 3660aef..be72145 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -310,3 +310,43 @@ class RsvpSubmitResponse(BaseModel): message: str guest_id: Optional[str] = None + +# ============================================ +# Contact Import Schemas +# ============================================ +class ImportContactRow(BaseModel): + """Represents a single row from an uploaded CSV / JSON import file.""" + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: Optional[str] = None # alternative: "Full Name" column + phone: Optional[str] = None + phone_number: Optional[str] = None + email: Optional[str] = None + rsvp_status: Optional[str] = None + meal_preference: Optional[str] = None + notes: Optional[str] = None + side: Optional[str] = None + table_number: Optional[str] = None + has_plus_one: Optional[bool] = None + plus_one_name: Optional[str] = None + + +class ImportRowResult(BaseModel): + """Per-row result returned in the import response.""" + row: int + action: str # "created" | "updated" | "skipped" | "error" + name: Optional[str] = None + phone: Optional[str] = None + reason: Optional[str] = None # for errors / skips + + +class ImportContactsResponse(BaseModel): + """Full response from POST /admin/import/contacts.""" + dry_run: bool + total: int + created: int + updated: int + skipped: int + errors: int + rows: List[ImportRowResult] + diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 9255a53..e7a6e8a 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -302,4 +302,29 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr return response.data } +// ============================================ +// Contact Import +// ============================================ + +/** + * Upload a CSV or JSON file and import its contacts into an event. + * + * @param {string} eventId - UUID of the target event + * @param {File} file - the user-selected CSV / JSON File object + * @param {boolean} dryRun - if true, preview only (no DB writes) + * @returns {ImportContactsResponse} + */ +export const importContacts = async (eventId, file, dryRun = false) => { + const form = new FormData() + form.append('file', file) + + const response = await api.post( + `/admin/import/contacts?event_id=${encodeURIComponent(eventId)}&dry_run=${dryRun}`, + form, + { headers: { 'Content-Type': 'multipart/form-data' } } + ) + return response.data +} + export default api + diff --git a/frontend/src/components/GuestList.jsx b/frontend/src/components/GuestList.jsx index 374051b..5656b26 100644 --- a/frontend/src/components/GuestList.jsx +++ b/frontend/src/components/GuestList.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api' import GuestForm from './GuestForm' import GoogleImport from './GoogleImport' +import ImportContacts from './ImportContacts' import SearchFilter from './SearchFilter' import DuplicateManager from './DuplicateManager' import WhatsAppInviteModal from './WhatsAppInviteModal' @@ -328,6 +329,7 @@ function GuestList({ eventId, onBack, onShowMembers }) { 🔍 חיפוש כפולויות + diff --git a/frontend/src/components/ImportContacts.css b/frontend/src/components/ImportContacts.css new file mode 100644 index 0000000..391defc --- /dev/null +++ b/frontend/src/components/ImportContacts.css @@ -0,0 +1,272 @@ +/* ImportContacts.css */ + +/* ── Trigger Button ──────────────────────────────────────────────────────── */ +.btn-import { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: 1.5px solid var(--border-color, #d1d5db); + border-radius: 8px; + background: var(--bg-secondary, #f9fafb); + color: var(--text-primary, #1f2937); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.btn-import:hover:not(:disabled) { + background: var(--bg-hover, #f3f4f6); + border-color: var(--accent, #6366f1); +} +.btn-import:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ── Modal Overlay ───────────────────────────────────────────────────────── */ +.import-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1100; + padding: 16px; +} + +.import-modal { + background: var(--bg-primary, #fff); + border-radius: 16px; + width: 100%; + max-width: 680px; + max-height: 88vh; + overflow-y: auto; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ +.import-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border-color, #e5e7eb); +} +.import-header h2 { + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary, #111827); + margin: 0; +} +.import-close { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + color: var(--text-secondary, #6b7280); + padding: 4px 8px; + border-radius: 6px; + line-height: 1; +} +.import-close:hover { background: var(--bg-hover, #f3f4f6); } + +/* ── Body ────────────────────────────────────────────────────────────────── */ +.import-body { + padding: 20px 24px 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── Drop Zone ───────────────────────────────────────────────────────────── */ +.drop-zone { + border: 2px dashed var(--border-color, #d1d5db); + border-radius: 12px; + padding: 32px 20px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + background: var(--bg-secondary, #fafafa); +} +.drop-zone:hover, .drop-zone.dragging { + border-color: var(--accent, #6366f1); + background: #eff0fe; +} +.drop-zone.has-file { + border-style: solid; + border-color: #10b981; + background: #ecfdf5; +} +.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; } +.drop-text { font-size: 1rem; font-weight: 600; margin: 0 0 4px; color: var(--text-primary, #111827); } +.drop-filename { font-size: 0.95rem; font-weight: 600; color: #10b981; margin: 0 0 4px; } +.drop-hint { font-size: 0.8rem; color: var(--text-secondary, #6b7280); margin: 0; } + +/* ── Format Hint ─────────────────────────────────────────────────────────── */ +.import-hint details { + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; + padding: 8px 12px; + font-size: 0.82rem; + color: var(--text-secondary, #6b7280); +} +.import-hint summary { cursor: pointer; font-weight: 600; } +.hint-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } +.hint-body code { + background: var(--bg-secondary, #f3f4f6); + padding: 4px 8px; + border-radius: 4px; + font-size: 0.78rem; + display: block; + word-break: break-all; +} + +/* ── Dry Run Toggle ──────────────────────────────────────────────────────── */ +.dry-run-toggle { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.875rem; + color: var(--text-primary, #374151); + cursor: pointer; +} +.dry-run-toggle input { width: 16px; height: 16px; cursor: pointer; } + +/* ── Error ───────────────────────────────────────────────────────────────── */ +.import-error { + background: #fef2f2; + border: 1px solid #fca5a5; + border-radius: 8px; + padding: 10px 14px; + font-size: 0.875rem; + color: #dc2626; +} + +/* ── Upload Button ───────────────────────────────────────────────────────── */ +.btn-upload { + width: 100%; + padding: 12px; + background: var(--accent, #6366f1); + color: #fff; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.btn-upload:hover:not(:disabled) { opacity: 0.9; } +.btn-upload:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Results ─────────────────────────────────────────────────────────────── */ +.import-results { display: flex; flex-direction: column; gap: 14px; } + +.results-banner { + padding: 10px 16px; + border-radius: 10px; + font-weight: 700; + font-size: 1rem; + text-align: center; +} +.results-banner.dry { background: #fffbeb; color: #d97706; border: 1px solid #fcd34d; } +.results-banner.live { background: #ecfdf5; color: #059669; border: 1px solid #6ee7b7; } + +.results-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.stat { + flex: 1; + min-width: 64px; + text-align: center; + background: var(--bg-secondary, #f9fafb); + border-radius: 10px; + padding: 10px 8px; + border: 1px solid var(--border-color, #e5e7eb); +} +.stat span { display: block; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #111827); } +.stat small { font-size: 0.75rem; color: var(--text-secondary, #6b7280); } +.stat.created span { color: #10b981; } +.stat.updated span { color: #3b82f6; } +.stat.skipped span { color: #9ca3af; } +.stat.errors span { color: #ef4444; } + +/* ── Rows table ──────────────────────────────────────────────────────────── */ +.results-table-wrap { + max-height: 280px; + overflow-y: auto; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; +} +.results-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} +.results-table th { + background: var(--bg-secondary, #f3f4f6); + padding: 7px 10px; + text-align: right; + font-weight: 600; + color: var(--text-secondary, #6b7280); + position: sticky; + top: 0; +} +.results-table td { + padding: 6px 10px; + border-top: 1px solid var(--border-color, #f3f4f6); + color: var(--text-primary, #374151); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row-error td { background: #fef2f2; } +.row-skipped td { color: var(--text-secondary, #9ca3af); } + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} +.badge-created { background: #d1fae5; color: #065f46; } +.badge-updated { background: #dbeafe; color: #1e40af; } +.badge-skipped { background: #f3f4f6; color: #6b7280; } +.badge-error { background: #fee2e2; color: #991b1b; } +.badge-dry { background: #fef3c7; color: #92400e; } + +/* ── Post-result actions ─────────────────────────────────────────────────── */ +.results-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.btn-reset { + padding: 9px 18px; + background: none; + border: 1.5px solid var(--border-color, #d1d5db); + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary, #374151); + font-weight: 500; +} +.btn-reset:hover { background: var(--bg-hover, #f9fafb); } +.btn-close-after { + padding: 9px 18px; + background: none; + border: 1.5px solid var(--border-color, #d1d5db); + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-secondary, #6b7280); +} +.btn-close-after:hover { background: var(--bg-hover, #f9fafb); } diff --git a/frontend/src/components/ImportContacts.jsx b/frontend/src/components/ImportContacts.jsx new file mode 100644 index 0000000..b16ce95 --- /dev/null +++ b/frontend/src/components/ImportContacts.jsx @@ -0,0 +1,250 @@ +import { useState, useRef } from 'react' +import { importContacts } from '../api/api' +import './ImportContacts.css' + +/** + * ImportContacts + * + * Opens a modal that lets the user upload a CSV or JSON file of contacts and + * import them into the current event's guest list. + * + * Props: + * eventId – UUID of the current event + * onImportComplete – callback called when a real (non-dry-run) import succeeds + */ +function ImportContacts({ eventId, onImportComplete }) { + const [open, setOpen] = useState(false) + const [file, setFile] = useState(null) + const [isDryRun, setIsDryRun] = useState(false) + const [dragging, setDragging] = useState(false) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) // ImportContactsResponse + const [error, setError] = useState('') + const fileInputRef = useRef() + + // ── helpers ──────────────────────────────────────────────────────────────── + + const reset = () => { + setFile(null) + setResult(null) + setError('') + setLoading(false) + setIsDryRun(false) + } + + const handleClose = () => { + setOpen(false) + reset() + } + + const handleFileChange = (e) => { + const f = e.target.files?.[0] + if (f) { setFile(f); setResult(null); setError('') } + } + + const handleDrop = (e) => { + e.preventDefault() + setDragging(false) + const f = e.dataTransfer.files?.[0] + if (f) { setFile(f); setResult(null); setError('') } + } + + // ── submit ───────────────────────────────────────────────────────────────── + + const handleUpload = async () => { + if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return } + setLoading(true) + setError('') + setResult(null) + + try { + const res = await importContacts(eventId, file, isDryRun) + setResult(res) + if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) { + onImportComplete() + } + } catch (err) { + const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.' + setError(typeof msg === 'string' ? msg : JSON.stringify(msg)) + } finally { + setLoading(false) + } + } + + // ── action label helpers ─────────────────────────────────────────────────── + + const actionLabel = { + created: { text: 'נוצר', cls: 'badge-created' }, + updated: { text: 'עודכן', cls: 'badge-updated' }, + skipped: { text: 'דולג', cls: 'badge-skipped' }, + error: { text: 'שגיאה', cls: 'badge-error' }, + would_create: { text: 'ייווצר', cls: 'badge-dry' }, + } + + // ── modal ────────────────────────────────────────────────────────────────── + + if (!open) { + return ( + + ) + } + + return ( +
e.target === e.currentTarget && handleClose()}> +
+ {/* Header */} +
+

📂 ייבוא אנשי קשר מקובץ

+ +
+ + {/* Body */} +
+ {/* File drop zone */} +
{ e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + + {file ? ( + <> + +

{file.name}

+

לחץ להחלפת הקובץ

+ + ) : ( + <> + 📄 +

גרור קובץ CSV או JSON לכאן

+

או לחץ לבחירת קובץ

+ + )} +
+ + {/* Format hint */} +
+
+ פורמטים נתמכים +
+

CSV — כל שורה = אורח. עמודות נתמכות:

+ First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table +

JSON — מערך של אובייקטים עם אותן שדות, או {`{"data":[…]}`}.

+

הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.

+
+
+
+ + {/* Dry-run toggle */} + + + {/* Error */} + {error &&
{error}
} + + {/* Upload button */} + {!result && ( + + )} + + {/* Results */} + {result && ( +
+
+ {result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'} +
+ +
+
{result.total}סה"כ
+
{result.created}{result.dry_run ? 'ייווצרו' : 'נוצרו'}
+
{result.updated}עודכנו
+
{result.skipped}דולגו
+ {result.errors > 0 && ( +
{result.errors}שגיאות
+ )} +
+ + {/* Row-level table */} + {result.rows.length > 0 && ( +
+ + + + + + + + + + + + {result.rows.map((r) => { + const lbl = actionLabel[r.action] || { text: r.action, cls: '' } + return ( + + + + + + + + ) + })} + +
#שםטלפוןפעולההערה
{r.row}{r.name || '—'}{r.phone || '—'}{lbl.text}{r.reason || ''}
+
+ )} + + {/* Post-result actions */} +
+ {result.dry_run && ( + + )} + + +
+
+ )} +
+
+
+ ) +} + +export default ImportContacts