Ready to merge before import from excel button
This commit is contained in:
parent
43fccacc47
commit
0574d76c35
336
backend/main.py
336
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=<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__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
388
backend/migrate_production.sql
Normal file
388
backend/migrate_production.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
111
backend/run_production_migration.py
Normal file
111
backend/run_production_migration.py
Normal 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()
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 }) {
|
||||
🔍 חיפוש כפולויות
|
||||
</button>
|
||||
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
||||
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
|
||||
<button className="btn-export" onClick={exportToExcel}>
|
||||
{he.exportExcel}
|
||||
</button>
|
||||
|
||||
272
frontend/src/components/ImportContacts.css
Normal file
272
frontend/src/components/ImportContacts.css
Normal 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); }
|
||||
250
frontend/src/components/ImportContacts.jsx
Normal file
250
frontend/src/components/ImportContacts.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user