feat: Foundation — auth system, 9 migrations, React frontend

Backend:
- Express server with JWT httpOnly cookie auth
- POST /api/auth/register, /api/auth/login, /api/auth/logout, GET /api/auth/me
- bcrypt 12 rounds, generic 401 errors (no email/password field disclosure)
- Auth middleware protects all /api/* routes except register/login
- pg Pool database connection

Frontend (React + Vite + TailwindCSS + shadcn/ui):
- AuthContext with session restore on page load via /api/auth/me
- ProtectedRoute redirects unauthenticated users to /login
- LoginPage, RegisterPage — Hebrew RTL layout (dir=rtl), inline validation
- DashboardPage placeholder
- shadcn/ui components: Button, Input, Label, Card

Database:
- 9 migrations (001-009): extensions, users, events, vendors, guests,
  bookings, invitations, vendor_ratings, organizer_preferences
- pg_trgm for fuzzy Hebrew search, GIN indexes on style_tags
- Phase 2+3 fields included: source, payment_status, contract_value,
  vendor ratings 6-dimension, organizer preferences
- Idempotent migration runner with schema_migrations tracking table

Infrastructure:
- Dockerfile (multi-stage: build React → production node:20-alpine)
- docker-compose.yml with PostgreSQL healthcheck, expose not ports
- Migrations run automatically on container start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:22:42 +00:00
parent 0f1882e9ae
commit c8909befb1
45 changed files with 5669 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
-- Migration 001: Enable required PostgreSQL extensions
-- UP
BEGIN;
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- required for Phase 2 fuzzy Hebrew name search
COMMIT;
-- DOWN
-- BEGIN;
-- DROP EXTENSION IF EXISTS "pg_trgm";
-- DROP EXTENSION IF EXISTS "pgcrypto";
-- COMMIT;

View File

@@ -0,0 +1,25 @@
-- Migration 002: Create users table
-- UP
BEGIN;
CREATE TYPE user_role AS ENUM ('organizer', 'vendor', 'admin');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'organizer',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS users;
-- DROP TYPE IF EXISTS user_role;
-- COMMIT;

View File

@@ -0,0 +1,43 @@
-- Migration 003: Create events table
-- UP
BEGIN;
CREATE TYPE event_status AS ENUM ('draft', 'published', 'cancelled', 'completed');
CREATE TYPE kashrut_level AS ENUM ('none', 'regular', 'mehadrin', 'chalav_yisrael');
CREATE TYPE event_language AS ENUM ('hebrew', 'arabic', 'english');
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organizer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
event_date TIMESTAMPTZ,
venue_name VARCHAR(255),
venue_address TEXT,
max_guests INTEGER,
venue_capacity INTEGER, -- fire-safety hard limit
max_plus_ones_buffer INTEGER NOT NULL DEFAULT 30, -- % buffer for walk-ins
status event_status NOT NULL DEFAULT 'draft',
kashrut_level kashrut_level NOT NULL DEFAULT 'none',
noise_curfew_time TIME NOT NULL DEFAULT '23:00', -- Israeli law default
language_pref event_language NOT NULL DEFAULT 'hebrew',
budget DECIMAL(12, 2),
retention_policy_days INTEGER NOT NULL DEFAULT 365, -- Israeli Privacy Law 2023
deleted_at TIMESTAMPTZ, -- soft delete for organizer use
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_events_organizer_id ON events(organizer_id);
CREATE INDEX idx_events_status ON events(status);
CREATE INDEX idx_events_event_date ON events(event_date);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS events;
-- DROP TYPE IF EXISTS event_language;
-- DROP TYPE IF EXISTS kashrut_level;
-- DROP TYPE IF EXISTS event_status;
-- COMMIT;

View File

@@ -0,0 +1,50 @@
-- Migration 004: Create vendors table
-- UP
BEGIN;
CREATE TYPE vendor_category AS ENUM (
'catering', 'photography', 'videographer', 'music', 'decoration',
'venue', 'officiant', 'staffing', 'transportation', 'printing',
'entertainment', 'other'
);
CREATE TABLE vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
business_name VARCHAR(255) NOT NULL,
category vendor_category NOT NULL,
description TEXT,
base_price DECIMAL(12, 2),
city VARCHAR(100),
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
-- Israeli compliance & certification fields
kashrut_cert_number VARCHAR(100),
kashrut_issuing_authority VARCHAR(255),
business_license_number VARCHAR(100),
license_expiry_date DATE, -- alert when within 30 days of expiry
insurance_ref VARCHAR(255),
-- Phase 3: AI recommendation fields
geographic_area VARCHAR(255), -- broader area (e.g. "North", "Tel Aviv District")
price_range_min DECIMAL(12, 2), -- NIS
price_range_max DECIMAL(12, 2), -- NIS
capacity_min INTEGER,
capacity_max INTEGER,
style_tags TEXT[], -- e.g. {"rustic","modern","religious"}
deleted_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_vendors_user_id ON vendors(user_id);
CREATE INDEX idx_vendors_category ON vendors(category);
CREATE INDEX idx_vendors_city ON vendors(city);
CREATE INDEX idx_vendors_geographic ON vendors(geographic_area);
CREATE INDEX idx_vendors_style_tags ON vendors USING GIN(style_tags);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS vendors;
-- DROP TYPE IF EXISTS vendor_category;
-- COMMIT;

View File

@@ -0,0 +1,56 @@
-- Migration 005: Create guests table
-- UP
BEGIN;
CREATE TYPE rsvp_status AS ENUM ('pending', 'confirmed', 'declined');
CREATE TYPE relationship_group AS ENUM ('family_bride', 'family_groom', 'friends', 'work', 'community', 'other');
CREATE TYPE dietary_preference AS ENUM ('none', 'vegetarian', 'vegan', 'kosher_regular', 'kosher_mehadrin');
CREATE TYPE guest_source AS ENUM ('registered', 'walkin'); -- Phase 2: analytics
CREATE TABLE guests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
-- Name (Hebrew required; Latin transliteration for non-Hebrew speakers)
name_hebrew VARCHAR(255) NOT NULL,
name_transliteration VARCHAR(255),
-- Contact (Israeli E.164 phone format: +972XXXXXXXXX)
email VARCHAR(255),
phone VARCHAR(20),
-- RSVP
rsvp_status rsvp_status NOT NULL DEFAULT 'pending',
-- Seating
table_number INTEGER,
seat_number VARCHAR(10),
-- Social grouping
relationship_group relationship_group,
plus_one_of UUID REFERENCES guests(id) ON DELETE SET NULL, -- self-ref FK
plus_one_allowance INTEGER NOT NULL DEFAULT 0,
-- Preferences
dietary_preference dietary_preference NOT NULL DEFAULT 'none',
dietary_notes TEXT, -- free-text override/additions
accessibility_needs TEXT,
-- Phase 2: Day-of check-in
source guest_source NOT NULL DEFAULT 'registered', -- analytics (walk-ins vs pre-registered)
-- Israeli Privacy Law 2023 compliance
privacy_accepted_at TIMESTAMPTZ,
-- NO deleted_at: guests support hard delete only (data subject right per Israeli Privacy Law)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_guests_event_id ON guests(event_id);
CREATE INDEX idx_guests_rsvp_status ON guests(rsvp_status);
CREATE INDEX idx_guests_plus_one_of ON guests(plus_one_of);
-- pg_trgm GIN index for Phase 2 fuzzy Hebrew name search (requires pg_trgm from migration 001)
CREATE INDEX idx_guests_name_trgm ON guests USING GIN(name_hebrew gin_trgm_ops);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS guests;
-- DROP TYPE IF EXISTS guest_source;
-- DROP TYPE IF EXISTS dietary_preference;
-- DROP TYPE IF EXISTS relationship_group;
-- DROP TYPE IF EXISTS rsvp_status;
-- COMMIT;

View File

@@ -0,0 +1,35 @@
-- Migration 006: Create bookings table
-- UP
BEGIN;
CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'cancelled');
CREATE TYPE payment_status AS ENUM ('unpaid', 'deposit_paid', 'fully_paid'); -- Phase 3: AI/financial
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
status booking_status NOT NULL DEFAULT 'pending',
agreed_price DECIMAL(12, 2),
notes TEXT,
-- Phase 3: AI recommendation & financial tracking
contract_value DECIMAL(12, 2), -- actual signed contract value in NIS
payment_status payment_status NOT NULL DEFAULT 'unpaid',
deleted_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_bookings_event_id ON bookings(event_id);
CREATE INDEX idx_bookings_vendor_id ON bookings(vendor_id);
CREATE INDEX idx_bookings_status ON bookings(status);
CREATE INDEX idx_bookings_payment_status ON bookings(payment_status);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS bookings;
-- DROP TYPE IF EXISTS payment_status;
-- DROP TYPE IF EXISTS booking_status;
-- COMMIT;

View File

@@ -0,0 +1,31 @@
-- Migration 007: Create invitations table
-- UP
BEGIN;
CREATE TYPE invitation_channel AS ENUM ('sms', 'whatsapp', 'email');
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(64), 'hex'),
channel invitation_channel NOT NULL DEFAULT 'whatsapp',
-- MVP: wa.me deep-link (no Twilio/API required)
-- Format: https://wa.me/+972XXXXXXXXX?text=ENCODED_MESSAGE
whatsapp_link TEXT, -- pre-generated deep-link for organizer to click
sent_at TIMESTAMPTZ, -- when organizer clicked Send
opened_at TIMESTAMPTZ, -- when guest opened the RSVP link
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invitations_event_id ON invitations(event_id);
CREATE INDEX idx_invitations_guest_id ON invitations(guest_id);
CREATE INDEX idx_invitations_token ON invitations(token);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS invitations;
-- DROP TYPE IF EXISTS invitation_channel;
-- COMMIT;

View File

@@ -0,0 +1,35 @@
-- Migration 008: Create vendor_ratings table (Phase 3: AI recommendation engine)
-- UP
BEGIN;
CREATE TABLE vendor_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
organizer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 6-dimension rating system (1-5 scale)
quality_score SMALLINT NOT NULL CHECK (quality_score BETWEEN 1 AND 5),
professionalism_score SMALLINT NOT NULL CHECK (professionalism_score BETWEEN 1 AND 5),
flexibility_score SMALLINT NOT NULL CHECK (flexibility_score BETWEEN 1 AND 5),
value_score SMALLINT NOT NULL CHECK (value_score BETWEEN 1 AND 5),
-- Boolean recommendation signals
would_use_again BOOLEAN NOT NULL,
would_recommend BOOLEAN NOT NULL,
-- Optional review text
review_text TEXT,
rated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- One rating per (event, vendor, organizer) tuple
CONSTRAINT uq_vendor_rating UNIQUE (event_id, vendor_id, organizer_id)
);
CREATE INDEX idx_vendor_ratings_vendor_id ON vendor_ratings(vendor_id);
CREATE INDEX idx_vendor_ratings_organizer_id ON vendor_ratings(organizer_id);
CREATE INDEX idx_vendor_ratings_event_id ON vendor_ratings(event_id);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS vendor_ratings;
-- COMMIT;

View File

@@ -0,0 +1,30 @@
-- Migration 009: Create organizer_preferences table (Phase 3: AI recommendation engine)
-- UP
BEGIN;
CREATE TABLE organizer_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Style preferences (matches vendors.style_tags for AI matching)
style_tags TEXT[], -- e.g. {"rustic","modern","religious"}
-- Typical event scale
typical_guest_count_min INTEGER,
typical_guest_count_max INTEGER,
-- Typical budget range in NIS
typical_budget_min DECIMAL(12, 2),
typical_budget_max DECIMAL(12, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- One preference record per user
CONSTRAINT uq_organizer_preferences_user UNIQUE (user_id)
);
CREATE INDEX idx_organizer_prefs_user_id ON organizer_preferences(user_id);
CREATE INDEX idx_organizer_prefs_style_tags ON organizer_preferences USING GIN(style_tags);
COMMIT;
-- DOWN
-- BEGIN;
-- DROP TABLE IF EXISTS organizer_preferences;
-- COMMIT;