Ready to merge before import from excel button

This commit is contained in:
dvirlabs 2026-03-01 01:02:18 +02:00
parent 43fccacc47
commit 0574d76c35
9 changed files with 1423 additions and 2 deletions

View File

@ -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.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
import uvicorn import uvicorn
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID, uuid4
import os import os
import io
import csv
import json
import secrets import secrets
import logging
from dotenv import load_dotenv from dotenv import load_dotenv
import httpx import httpx
from urllib.parse import urlencode, quote from urllib.parse import urlencode, quote
from datetime import timezone, timedelta from datetime import timezone, timedelta
logger = logging.getLogger(__name__)
import models import models
import schemas import schemas
import crud import crud
@ -1463,5 +1469,331 @@ def rsvp_submit(
) )
# ============================================
# Contact Import Endpoint
# POST /admin/import/contacts?event_id=<uuid>&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__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

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

View File

@ -5,3 +5,4 @@ psycopg2-binary>=2.9.9
pydantic[email]>=2.5.0 pydantic[email]>=2.5.0
httpx>=0.25.2 httpx>=0.25.2
python-dotenv>=1.0.0 python-dotenv>=1.0.0
python-multipart>=0.0.7

View File

@ -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()

View File

@ -310,3 +310,43 @@ class RsvpSubmitResponse(BaseModel):
message: str message: str
guest_id: Optional[str] = None 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]

View File

@ -302,4 +302,29 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
return response.data 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 export default api

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api' import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
import GuestForm from './GuestForm' import GuestForm from './GuestForm'
import GoogleImport from './GoogleImport' import GoogleImport from './GoogleImport'
import ImportContacts from './ImportContacts'
import SearchFilter from './SearchFilter' import SearchFilter from './SearchFilter'
import DuplicateManager from './DuplicateManager' import DuplicateManager from './DuplicateManager'
import WhatsAppInviteModal from './WhatsAppInviteModal' import WhatsAppInviteModal from './WhatsAppInviteModal'
@ -328,6 +329,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
🔍 חיפוש כפולויות 🔍 חיפוש כפולויות
</button> </button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} /> <GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-export" onClick={exportToExcel}> <button className="btn-export" onClick={exportToExcel}>
{he.exportExcel} {he.exportExcel}
</button> </button>

View File

@ -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); }

View File

@ -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 (
<button
className="btn btn-import"
onClick={() => setOpen(true)}
disabled={!eventId}
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
>
📂 ייבוא קובץ
</button>
)
}
return (
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
<div className="import-modal" dir="rtl">
{/* Header */}
<div className="import-header">
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
<button className="import-close" onClick={handleClose}></button>
</div>
{/* Body */}
<div className="import-body">
{/* File drop zone */}
<div
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{file ? (
<>
<span className="drop-icon"></span>
<p className="drop-filename">{file.name}</p>
<p className="drop-hint">לחץ להחלפת הקובץ</p>
</>
) : (
<>
<span className="drop-icon">📄</span>
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
<p className="drop-hint">או לחץ לבחירת קובץ</p>
</>
)}
</div>
{/* Format hint */}
<div className="import-hint">
<details>
<summary>פורמטים נתמכים</summary>
<div className="hint-body">
<p><strong>CSV</strong> כל שורה = אורח. עמודות נתמכות:</p>
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
<p><strong>JSON</strong> מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
</div>
</details>
</div>
{/* Dry-run toggle */}
<label className="dry-run-toggle">
<input
type="checkbox"
checked={isDryRun}
onChange={(e) => setIsDryRun(e.target.checked)}
/>
<span>בדיקה בלבד (Dry Run) הצג מה היה קורה ללא שמירה</span>
</label>
{/* Error */}
{error && <div className="import-error">{error}</div>}
{/* Upload button */}
{!result && (
<button
className="btn-upload"
onClick={handleUpload}
disabled={loading || !file}
>
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
</button>
)}
{/* Results */}
{result && (
<div className="import-results">
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
</div>
<div className="results-stats">
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
{result.errors > 0 && (
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
)}
</div>
{/* Row-level table */}
{result.rows.length > 0 && (
<div className="results-table-wrap">
<table className="results-table">
<thead>
<tr>
<th>#</th>
<th>שם</th>
<th>טלפון</th>
<th>פעולה</th>
<th>הערה</th>
</tr>
</thead>
<tbody>
{result.rows.map((r) => {
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
return (
<tr key={r.row} className={`row-${r.action}`}>
<td>{r.row}</td>
<td>{r.name || '—'}</td>
<td dir="ltr">{r.phone || '—'}</td>
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
<td>{r.reason || ''}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Post-result actions */}
<div className="results-actions">
{result.dry_run && (
<button
className="btn-upload"
onClick={() => { setIsDryRun(false); setResult(null) }}
>
אישור ייבא עכשיו
</button>
)}
<button className="btn-reset" onClick={reset}>
📂 ייבא קובץ חדש
</button>
<button className="btn-close-after" onClick={handleClose}>
סגור
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default ImportContacts