+ {deleteTarget.active_venue_count} {deleteTarget.active_venue_count === 1 ? 'venue is' : 'venues are'} currently using this amenity.{' '}
+ Their existing listings will be unaffected, but this amenity won't appear in the picker for new selections.
+
+ {deleteTarget.venue_count} {deleteTarget.venue_count === 1 ? 'venue uses' : 'venues use'} this category.{' '}
+ Existing venues are unaffected but this category won't appear for new venues.
+
+ }
+ title="No queries found"
+ description={search ? 'Try a different search term.' : 'Deep Research queries will appear here once users start searching.'}
+ />
+
+ We tried emailing {ownerEmail} an account-setup link. If email delivery isn't working yet,
+ copy this link and share it with them directly (WhatsApp, SMS, etc.) — it's one-time use.
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
Invite owner
+
+ Creates the owner's account and a draft venue in{' '}
+ {inviteTarget?.category_label ?? 'the category the customer picked'},
+ then emails them an account-setup link.
+
+
+ {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This user'}
+ {' '}
+ will be notified their application was not approved. They can re-apply later.
+
+
+ {approveTarget?.full_name ?? approveTarget?.email ?? 'This applicant'}
+ {' '}
+ will be granted full venue owner access and can start listing venues immediately.
+
+ {approveError && (
+
+ {approveError}
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Reject Modal */}
+
+
+
+
+
+
+
Reject application
+
+
+ {rejectTarget?.full_name ?? rejectTarget?.email ?? 'This applicant'}
+ {' '}
+ will be notified their application was not approved. They can re-apply after reviewing the feedback.
+
+
+ {suspendTarget?.full_name ?? suspendTarget?.email ?? 'This owner'}
+ {' '}
+ will immediately lose platform access. Their active venues will be hidden until the account is reactivated.
+
+
+ {reactivateTarget?.full_name ?? reactivateTarget?.email ?? 'This owner'}
+ {' '}
+ will regain full venue owner access immediately.
+
+ {reactivateError && (
+
+ {reactivateError}
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin-panel/src/routes.tsx b/apps/admin-panel/src/routes.tsx
new file mode 100644
index 000000000..6a3df791c
--- /dev/null
+++ b/apps/admin-panel/src/routes.tsx
@@ -0,0 +1,103 @@
+import { createBrowserRouter, Navigate } from 'react-router-dom'
+import { ProtectedRoute } from './components/ProtectedRoute'
+import Dashboard from './pages/Dashboard'
+import Login from './pages/Login'
+import LoginSuccess from './pages/LoginSuccess'
+import Forbidden from './pages/Forbidden'
+import NotFound from './pages/NotFound'
+import ComingSoon from './pages/ComingSoon'
+import Bookings from './pages/Bookings'
+import Users from './pages/Users'
+import VenueOwners from './pages/VenueOwners'
+import VenueApprovals from './pages/VenueApprovals'
+import Amenities from './pages/Amenities'
+import Categories from './pages/Categories'
+import AuditLog from './pages/AuditLog'
+import DeepResearchInsights from './pages/DeepResearchInsights'
+import ExternalReservations from './pages/ExternalReservations'
+
+export const router = createBrowserRouter([
+ // Auth
+ { path: '/login', element: },
+ { path: '/login/success', element: },
+ { path: '/403', element: },
+
+ // Root → dashboard
+ { path: '/', element: },
+
+ // Protected routes
+ {
+ path: '/dashboard',
+ element: ,
+ },
+ {
+ path: '/venues/pending',
+ element: ,
+ },
+ {
+ path: '/users',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/owners',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/amenities',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/categories',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/bookings',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/audit-log',
+ element: ,
+ },
+ {
+ path: '/deep-research-insights',
+ element: ,
+ },
+ {
+ path: '/external-reservations',
+ element: ,
+ },
+ {
+ path: '/settings',
+ element: (
+
+
+
+ ),
+ },
+
+ // Catch-all → 404
+ { path: '*', element: },
+])
diff --git a/apps/admin-panel/src/styles/index.css b/apps/admin-panel/src/styles/index.css
new file mode 100644
index 000000000..b80916285
--- /dev/null
+++ b/apps/admin-panel/src/styles/index.css
@@ -0,0 +1,3 @@
+@import "tailwindcss";
+@import "@venue404/ui/theme.css";
+@source "../../../../packages/ui/src";
diff --git a/apps/admin-panel/src/vite-env.d.ts b/apps/admin-panel/src/vite-env.d.ts
new file mode 100644
index 000000000..1c5248e7c
--- /dev/null
+++ b/apps/admin-panel/src/vite-env.d.ts
@@ -0,0 +1,24 @@
+// Ambient declarations for static asset imports (Vite resolves these at build).
+// Note: we intentionally do NOT `/// ` here —
+// it redeclares import.meta.env and conflicts with @venue404/api-client's own
+// ImportMeta.env declaration (TS2717).
+declare module '*.png' {
+ const src: string
+ export default src
+}
+declare module '*.jpg' {
+ const src: string
+ export default src
+}
+declare module '*.jpeg' {
+ const src: string
+ export default src
+}
+declare module '*.svg' {
+ const src: string
+ export default src
+}
+declare module '*.webp' {
+ const src: string
+ export default src
+}
diff --git a/apps/admin-panel/tsconfig.json b/apps/admin-panel/tsconfig.json
new file mode 100644
index 000000000..351ec2e37
--- /dev/null
+++ b/apps/admin-panel/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "baseUrl": "."
+ },
+ "include": ["src"]
+}
diff --git a/apps/admin-panel/vercel.json b/apps/admin-panel/vercel.json
new file mode 100644
index 000000000..88dccfdcd
--- /dev/null
+++ b/apps/admin-panel/vercel.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "vite",
+ "buildCommand": "pnpm build",
+ "outputDirectory": "dist"
+}
diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts
new file mode 100644
index 000000000..82cbf6cae
--- /dev/null
+++ b/apps/admin-panel/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import { brandingPlugin } from '../../packages/assets/vite-branding'
+
+export default defineConfig({
+ plugins: [brandingPlugin(), tailwindcss(), react()],
+ server: {
+ port: 5399,
+ proxy: {
+ '/api': 'http://localhost:8000',
+ },
+ },
+})
diff --git a/apps/api/.env.example b/apps/api/.env.example
new file mode 100644
index 000000000..9fa5d472d
--- /dev/null
+++ b/apps/api/.env.example
@@ -0,0 +1,73 @@
+# Database (Supabase direct connection - use this for Alembic migrations)
+# Find in: Supabase Dashboard -> Project Settings -> Database -> Connection string -> URI
+DATABASE_URL=postgresql://postgres.qcctiopmoscndyyitzpg:[YOUR-PASSWORD]@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres
+
+# Supabase
+SUPABASE_URL=https://your-project-ref.supabase.co
+SUPABASE_JWT_SECRET=your-supabase-jwt-secret
+SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
+
+# Stripe
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
+STRIPE_CURRENCY=inr
+
+# Email - Resend is primary, SMTP is the fallback transport
+EMAIL_FROM=Venue404
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASSWORD=
+
+# Deep links inside notification emails
+FRONTEND_BASE_URL=http://localhost:5397
+
+
+OWNER_PORTAL_BASE_URL=http://localhost:5398
+
+# Comma-separated list of allowed browser origins for CORS
+CORS_ORIGINS=http://localhost:5397,http://localhost:5398,http://localhost:5399
+
+# Booking economics (percent of venue price)
+TOKEN_ADVANCE_PCT=20
+PLATFORM_FEE_PCT=5
+
+# Background jobs - enable on exactly ONE process/instance
+ENABLE_JOBS=false
+
+# Shared secret guarding the machine-to-machine job-runner endpoint.
+# Empty disables the endpoint (returns 503). Set to a long random value in prod.
+JOB_RUNNER_TOKEN=
+
+# Super Admin
+SUPER_ADMIN_NAME=
+SUPER_ADMIN_EMAIL=
+SUPER_ADMIN_PASSWORD=
+
+# Cloudinary
+CLOUDINARY_CLOUD_NAME=
+CLOUDINARY_API_KEY=
+CLOUDINARY_API_SECRET=
+
+# Upstash Redis - fast delivery channel for search index jobs.
+# If unset, the worker falls back to polling search_index_jobs directly.
+UPSTASH_REDIS_URL=
+UPSTASH_REDIS_TOKEN=
+UPSTASH_SEARCH_QUEUE_KEY=search_index_jobs
+
+# Jina AI - used to generate venue embeddings for semantic search
+JINA_API_KEY=
+JINA_EMBEDDING_MODEL=jina-embeddings-v3
+EMBEDDING_DIMENSIONS=1024
+
+# Groq API Key for LLM/SLM calls
+GROQ_API_KEY=
+GROQ_MODEL=llama-3.1-8b-instant
+GROQ_BASE_URL=https://api.groq.com/openai/v1
+
+# Google Places API Key for external venue search
+GOOGLE_PLACES_API_KEY=
+
+LOG_LEVEL=INFO
+
+search_min_vector_similarity=
\ No newline at end of file
diff --git a/apps/api/.env.test b/apps/api/.env.test
new file mode 100644
index 000000000..e94d8d400
--- /dev/null
+++ b/apps/api/.env.test
@@ -0,0 +1,6 @@
+DATABASE_URL=postgresql://user:pass@localhost:5432/venue404_test
+SUPABASE_URL=https://example.supabase.co
+SUPABASE_JWT_SECRET=test-secret
+SUPABASE_SERVICE_ROLE_KEY=test-service-role
+STRIPE_SECRET_KEY=sk_test_dummy
+STRIPE_WEBHOOK_SECRET=whsec_dummy
diff --git a/apps/api/.python-version b/apps/api/.python-version
new file mode 100644
index 000000000..e4fba2183
--- /dev/null
+++ b/apps/api/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
new file mode 100644
index 000000000..547373ae5
--- /dev/null
+++ b/apps/api/Dockerfile
@@ -0,0 +1,11 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY pyproject.toml .
+RUN pip install --no-cache-dir uv && uv pip install --system -e .
+
+COPY . .
+
+# Shell form so $PORT (injected by Render) expands; defaults to 8000 locally.
+CMD ["sh", "-c", "uvicorn app.main:app --reload --host 0.0.0.0 --port ${PORT:-8000}"]
diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini
new file mode 100644
index 000000000..581cac9a7
--- /dev/null
+++ b/apps/api/alembic.ini
@@ -0,0 +1,38 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = placeholder_overridden_by_env_py
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py
new file mode 100644
index 000000000..0ce9d276a
--- /dev/null
+++ b/apps/api/alembic/env.py
@@ -0,0 +1,63 @@
+# ruff: noqa: I001
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.core.config import settings
+from app.core.database import Base
+
+# import all models so Base.metadata is populated for autogenerate
+import app.modules.admin.models # noqa: F401
+import app.modules.booking.models # noqa: F401
+import app.modules.deep_research.models # noqa: F401
+import app.modules.notification.models # noqa: F401
+import app.modules.payment.models # noqa: F401
+import app.modules.profile.models # noqa: F401
+import app.modules.review.models # noqa: F401
+import app.modules.search.models # noqa: F401
+import app.modules.venue.models # noqa: F401
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def get_url() -> str:
+ url = settings.database_url
+ # Alembic needs a sync driver — strip +asyncpg if present
+ return url.replace("postgresql+asyncpg://", "postgresql://")
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=get_url(),
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ configuration = config.get_section(config.config_ini_section, {})
+ configuration["sqlalchemy.url"] = get_url()
+ connectable = engine_from_config(
+ configuration,
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/apps/api/alembic/script.py.mako b/apps/api/alembic/script.py.mako
new file mode 100644
index 000000000..4940cfda6
--- /dev/null
+++ b/apps/api/alembic/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/apps/api/alembic/versions/.gitkeep b/apps/api/alembic/versions/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/alembic/versions/0001_auth_schema.py b/apps/api/alembic/versions/0001_auth_schema.py
new file mode 100644
index 000000000..a75ad1419
--- /dev/null
+++ b/apps/api/alembic/versions/0001_auth_schema.py
@@ -0,0 +1,123 @@
+"""auth schema - profiles, user_roles, admin_actions
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-06-02
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import UUID, ENUM as PgEnum
+
+revision: str = "0001"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # create enums via raw SQL — DO block silently skips if already exists
+ op.execute("""
+ DO $$ BEGIN
+ CREATE TYPE profile_status AS ENUM ('active', 'suspended');
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$;
+ """)
+ op.execute("""
+ DO $$ BEGIN
+ CREATE TYPE user_role AS ENUM ('customer', 'venue_owner', 'super_admin');
+ EXCEPTION WHEN duplicate_object THEN NULL;
+ END $$;
+ """)
+
+ # profiles — id mirrors auth.users.id (Supabase manages auth.users)
+ op.create_table(
+ "profiles",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("full_name", sa.String, nullable=True),
+ sa.Column("phone", sa.String, nullable=True),
+ sa.Column("avatar_url", sa.String, nullable=True),
+ sa.Column(
+ "status",
+ PgEnum("active", "suspended", name="profile_status", create_type=False),
+ nullable=False,
+ server_default="active",
+ ),
+ sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["id"],
+ ["auth.users.id"],
+ name="fk_profiles_auth_users",
+ ondelete="CASCADE",
+ ),
+ )
+
+ # user_roles
+ op.create_table(
+ "user_roles",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
+ sa.Column("user_id", UUID(as_uuid=True), nullable=False),
+ sa.Column(
+ "role",
+ PgEnum("customer", "venue_owner", "super_admin", name="user_role", create_type=False),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["profiles.id"],
+ name="fk_user_roles_profile",
+ ondelete="CASCADE",
+ ),
+ sa.UniqueConstraint("user_id", "role", name="uq_user_roles_user_role"),
+ )
+
+ # admin_actions — append-only audit log
+ op.create_table(
+ "admin_actions",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
+ sa.Column("admin_id", UUID(as_uuid=True), nullable=False),
+ sa.Column("action_type", sa.String, nullable=False),
+ sa.Column("target_type", sa.String, nullable=False),
+ sa.Column("target_id", UUID(as_uuid=True), nullable=False),
+ sa.Column("reason", sa.Text, nullable=True),
+ sa.Column("metadata", sa.JSON, nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["admin_id"],
+ ["profiles.id"],
+ name="fk_admin_actions_profile",
+ ondelete="RESTRICT",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("admin_actions")
+ op.drop_table("user_roles")
+ op.drop_table("profiles")
+ sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
+ sa.Enum(name="profile_status").drop(op.get_bind(), checkfirst=True)
diff --git a/apps/api/alembic/versions/0002_signup_trigger.py b/apps/api/alembic/versions/0002_signup_trigger.py
new file mode 100644
index 000000000..62fe12c63
--- /dev/null
+++ b/apps/api/alembic/versions/0002_signup_trigger.py
@@ -0,0 +1,59 @@
+"""signup trigger - auto-create profile and customer role on auth.users insert
+
+Revision ID: 0002
+Revises: 0001
+Create Date: 2026-06-02
+
+"""
+from typing import Sequence, Union
+from alembic import op
+
+revision: str = "0002"
+down_revision: Union[str, None] = "0001"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ SECURITY DEFINER SET search_path = public
+ AS $$
+ BEGIN
+ INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at)
+ VALUES (
+ NEW.id,
+ NEW.raw_user_meta_data ->> 'full_name',
+ NEW.raw_user_meta_data ->> 'phone',
+ 'active',
+ now(),
+ now()
+ )
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (
+ gen_random_uuid(),
+ NEW.id,
+ 'customer',
+ now()
+ )
+ ON CONFLICT (user_id, role) DO NOTHING;
+
+ RETURN NEW;
+ END;
+ $$;
+ """)
+
+ op.execute("""
+ CREATE OR REPLACE TRIGGER on_auth_user_created
+ AFTER INSERT ON auth.users
+ FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
+ """)
+
+
+def downgrade() -> None:
+ op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;")
+ op.execute("DROP FUNCTION IF EXISTS public.handle_new_user();")
diff --git a/apps/api/alembic/versions/0003_profile_email.py b/apps/api/alembic/versions/0003_profile_email.py
new file mode 100644
index 000000000..5a90bd736
--- /dev/null
+++ b/apps/api/alembic/versions/0003_profile_email.py
@@ -0,0 +1,25 @@
+"""profiles: add email column
+
+Revision ID: 0003
+Revises: 0002
+Create Date: 2026-06-04
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = "0003"
+down_revision: Union[str, None] = "0002"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("profiles", sa.Column("email", sa.String, nullable=True))
+ op.create_index("ix_profiles_email", "profiles", ["email"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_profiles_email", table_name="profiles")
+ op.drop_column("profiles", "email")
diff --git a/apps/api/alembic/versions/0004_venue_schema.py b/apps/api/alembic/versions/0004_venue_schema.py
new file mode 100644
index 000000000..fa219f92e
--- /dev/null
+++ b/apps/api/alembic/versions/0004_venue_schema.py
@@ -0,0 +1,216 @@
+"""feat(venue): add venue domain models
+
+Revision ID: 9170dab48e33
+Revises: 0003
+Create Date: 2026-06-05 15:14:00.136309
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '9170dab48e33'
+down_revision: Union[str, None] = '0003'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('amenities',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('name', sa.Text(), nullable=False),
+ sa.Column('icon', sa.Text(), nullable=True),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+ op.create_table('venues',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('owner_id', sa.UUID(), nullable=False),
+ sa.Column('name', sa.Text(), nullable=False),
+ sa.Column('slug', sa.Text(), nullable=True),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('venue_type', sa.Text(), nullable=False),
+ sa.Column('address_line1', sa.Text(), nullable=False),
+ sa.Column('address_line2', sa.Text(), nullable=True),
+ sa.Column('city', sa.Text(), nullable=False),
+ sa.Column('state', sa.Text(), nullable=False),
+ sa.Column('country', sa.Text(), server_default=sa.text("'India'"), nullable=False),
+ sa.Column('postal_code', sa.Text(), nullable=True),
+ sa.Column('latitude', sa.Numeric(precision=10, scale=7), nullable=True),
+ sa.Column('longitude', sa.Numeric(precision=10, scale=7), nullable=True),
+ sa.Column('timezone', sa.Text(), server_default=sa.text("'Asia/Kolkata'"), nullable=False),
+ sa.Column('min_capacity', sa.Integer(), nullable=True),
+ sa.Column('max_capacity', sa.Integer(), nullable=False),
+ sa.Column('open_time', sa.Time(), nullable=False),
+ sa.Column('close_time', sa.Time(), nullable=False),
+ sa.Column('spans_next_day', sa.Boolean(), server_default='false', nullable=False),
+ sa.Column('allowed_booking_types', postgresql.ARRAY(sa.Text()), server_default=sa.text("ARRAY['full_day','time_slot']"), nullable=False),
+ sa.Column('min_booking_duration_minutes', sa.Integer(), server_default='60', nullable=False),
+ sa.Column('max_booking_duration_minutes', sa.Integer(), server_default='1440', nullable=False),
+ sa.Column('slot_interval_minutes', sa.Integer(), server_default='30', nullable=False),
+ sa.Column('pre_buffer_minutes', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('post_buffer_minutes', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('pricing_mode', sa.Text(), server_default=sa.text("'flat'"), nullable=False),
+ sa.Column('base_price_paise', sa.BigInteger(), nullable=True),
+ sa.Column('hourly_rate_paise', sa.BigInteger(), nullable=True),
+ sa.Column('platform_commission_pct', sa.Numeric(precision=5, scale=2), server_default='10.00', nullable=False),
+ sa.Column('advance_pct', sa.Numeric(precision=5, scale=2), server_default='30.00', nullable=False),
+ sa.Column('balance_due_days_before_event', sa.Integer(), server_default='7', nullable=False),
+ sa.Column('owner_action_window_hours', sa.Integer(), server_default='48', nullable=False),
+ sa.Column('overdue_advance_refund_pct', sa.Numeric(precision=5, scale=2), server_default='0.00', nullable=False),
+ sa.Column('status', sa.Enum('draft', 'pending_approval', 'approved', 'rejected', 'suspended', name='venue_status'), server_default=sa.text("'draft'"), nullable=False),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.CheckConstraint("pricing_mode IN ('flat', 'hourly')", name='ck_venues_pricing_mode'),
+ sa.CheckConstraint('advance_pct > 0 AND advance_pct <= 100', name='ck_venues_advance_pct'),
+ sa.CheckConstraint('balance_due_days_before_event > 0', name='ck_venues_balance_days'),
+ sa.CheckConstraint('base_price_paise >= 0', name='ck_venues_base_price'),
+ sa.CheckConstraint('hourly_rate_paise >= 0', name='ck_venues_hourly_rate'),
+ sa.CheckConstraint('max_booking_duration_minutes > 0', name='ck_venues_max_duration'),
+ sa.CheckConstraint('max_capacity > 0', name='ck_venues_max_capacity'),
+ sa.CheckConstraint('min_booking_duration_minutes <= max_booking_duration_minutes', name='ck_venues_duration_range'),
+ sa.CheckConstraint('min_booking_duration_minutes > 0', name='ck_venues_min_duration'),
+ sa.CheckConstraint('min_capacity > 0', name='ck_venues_min_capacity'),
+ sa.CheckConstraint('min_capacity IS NULL OR min_capacity <= max_capacity', name='ck_venues_capacity'),
+ sa.CheckConstraint('overdue_advance_refund_pct BETWEEN 0 AND 100', name='ck_venues_overdue_refund_pct'),
+ sa.CheckConstraint('owner_action_window_hours BETWEEN 24 AND 72', name='ck_venues_action_window'),
+ sa.CheckConstraint('platform_commission_pct >= 0 AND platform_commission_pct <= 100', name='ck_venues_commission'),
+ sa.CheckConstraint('post_buffer_minutes >= 0', name='ck_venues_post_buffer'),
+ sa.CheckConstraint('pre_buffer_minutes >= 0', name='ck_venues_pre_buffer'),
+ sa.CheckConstraint('slot_interval_minutes > 0', name='ck_venues_slot_interval'),
+ sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('slug')
+ )
+ op.create_index('idx_venues_search', 'venues', ['city', 'venue_type', 'status', 'is_active'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_table('venue_amenities',
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('amenity_id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['amenity_id'], ['amenities.id'], ),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('venue_id', 'amenity_id')
+ )
+ op.create_table('venue_availability',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('day_of_week', sa.Integer(), nullable=False),
+ sa.Column('is_available', sa.Boolean(), server_default='true', nullable=False),
+ sa.Column('opens_at', sa.Time(), nullable=True),
+ sa.Column('closes_at', sa.Time(), nullable=True),
+ sa.Column('spans_next_day', sa.Boolean(), server_default='false', nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.CheckConstraint('day_of_week BETWEEN 0 AND 6', name='ck_venue_availability_day'),
+ sa.CheckConstraint('is_available = false OR (opens_at IS NOT NULL AND closes_at IS NOT NULL)', name='ck_venue_availability_times'),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('venue_availability_unique_day', 'venue_availability', ['venue_id', 'day_of_week'], unique=True, postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_table('venue_blocked_dates',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('reason', sa.Text(), nullable=True),
+ sa.Column('blocked_by', sa.UUID(), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.CheckConstraint('ends_at > starts_at', name='ck_venue_blocked_dates_order'),
+ sa.ForeignKeyConstraint(['blocked_by'], ['profiles.id'], ),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_venue_blocked_dates_venue', 'venue_blocked_dates', ['venue_id'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_table('venue_cancellation_policies',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('tier_1_hours', sa.Integer(), nullable=True),
+ sa.Column('tier_1_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True),
+ sa.Column('tier_2_hours', sa.Integer(), nullable=True),
+ sa.Column('tier_2_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True),
+ sa.Column('tier_3_hours', sa.Integer(), nullable=True),
+ sa.Column('tier_3_refund_pct', sa.Numeric(precision=5, scale=2), nullable=True),
+ sa.Column('no_show_refund_pct', sa.Numeric(precision=5, scale=2), server_default='0', nullable=False),
+ sa.Column('platform_fee_refundable', sa.Boolean(), server_default='false', nullable=False),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.CheckConstraint('(tier_1_hours IS NULL OR tier_2_hours IS NULL OR tier_1_hours > tier_2_hours) AND (tier_2_hours IS NULL OR tier_3_hours IS NULL OR tier_2_hours > tier_3_hours)', name='tiers_descending'),
+ sa.CheckConstraint('(tier_1_hours IS NULL) = (tier_1_refund_pct IS NULL)', name='tier_1_paired'),
+ sa.CheckConstraint('(tier_2_hours IS NULL) = (tier_2_refund_pct IS NULL)', name='tier_2_paired'),
+ sa.CheckConstraint('(tier_3_hours IS NULL) = (tier_3_refund_pct IS NULL)', name='tier_3_paired'),
+ sa.CheckConstraint('no_show_refund_pct BETWEEN 0 AND 100', name='ck_vcp_no_show_pct'),
+ sa.CheckConstraint('tier_1_hours > 0', name='ck_vcp_tier_1_hours'),
+ sa.CheckConstraint('tier_1_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_1_pct'),
+ sa.CheckConstraint('tier_2_hours > 0', name='ck_vcp_tier_2_hours'),
+ sa.CheckConstraint('tier_2_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_2_pct'),
+ sa.CheckConstraint('tier_3_hours > 0', name='ck_vcp_tier_3_hours'),
+ sa.CheckConstraint('tier_3_refund_pct BETWEEN 0 AND 100', name='ck_vcp_tier_3_pct'),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('venue_id')
+ )
+ op.create_table('venue_photos',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('image_url', sa.Text(), nullable=False),
+ sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('is_cover', sa.Boolean(), server_default='false', nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('venue_photos_one_cover', 'venue_photos', ['venue_id'], unique=True, postgresql_where=sa.text('is_cover = true AND deleted_at IS NULL'))
+
+
+ op.execute("""
+ CREATE OR REPLACE FUNCTION set_updated_at()
+ RETURNS TRIGGER AS $$
+ BEGIN NEW.updated_at = now(); RETURN NEW; END;
+ $$ LANGUAGE plpgsql;
+ """)
+
+ op.execute("""
+ CREATE TRIGGER trg_venues_updated_at
+ BEFORE UPDATE ON venues
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+ """)
+
+ op.execute("""
+ CREATE TRIGGER trg_venue_availability_updated_at
+ BEFORE UPDATE ON venue_availability
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+ """)
+
+ op.execute("""
+ CREATE TRIGGER trg_venue_cancellation_policies_updated_at
+ BEFORE UPDATE ON venue_cancellation_policies
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+ """)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('venue_photos_one_cover', table_name='venue_photos', postgresql_where=sa.text('is_cover = true AND deleted_at IS NULL'))
+ op.drop_table('venue_photos')
+ op.drop_table('venue_cancellation_policies')
+ op.drop_index('idx_venue_blocked_dates_venue', table_name='venue_blocked_dates', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.drop_table('venue_blocked_dates')
+ op.drop_index('venue_availability_unique_day', table_name='venue_availability', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.drop_table('venue_availability')
+ op.drop_table('venue_amenities')
+ op.drop_index('idx_venues_search', table_name='venues', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.drop_table('venues')
+ op.drop_table('amenities')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py b/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py
new file mode 100644
index 000000000..c934a47e8
--- /dev/null
+++ b/apps/api/alembic/versions/0005_add_mixed_pricing_mode_to_venues.py
@@ -0,0 +1,28 @@
+"""add mixed pricing mode to venues
+
+Revision ID: 1ce66f7c8bf4
+Revises: 9170dab48e33
+Create Date: 2026-06-06 08:33:44.531549
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '1ce66f7c8bf4'
+down_revision: Union[str, None] = '9170dab48e33'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ op.drop_constraint('ck_venues_pricing_mode', 'venues', type_='check')
+ op.create_check_constraint('ck_venues_pricing_mode', 'venues', "pricing_mode IN ('flat', 'hourly', 'mixed')")
+
+
+def downgrade() -> None:
+
+ op.drop_constraint('ck_venues_pricing_mode', 'venues', type_='check')
+ op.create_check_constraint('ck_venues_pricing_mode', 'venues', "pricing_mode IN ('flat', 'hourly')")
diff --git a/apps/api/alembic/versions/0005_booking_schema.py b/apps/api/alembic/versions/0005_booking_schema.py
new file mode 100644
index 000000000..c6098aaff
--- /dev/null
+++ b/apps/api/alembic/versions/0005_booking_schema.py
@@ -0,0 +1,40 @@
+"""booking_schema
+
+Revision ID: 0c70837a672c
+Revises: 9170dab48e33
+Create Date: 2026-06-06 12:09:27.541886
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '0c70837a672c'
+down_revision: Union[str, None] = '9170dab48e33'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('bookings',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('booking_type', sa.Enum('full_day', 'time_slot', name='booking_type'), nullable=False),
+ sa.Column('status', sa.Enum('requested', 'owner_accepted', 'confirmed', 'completed', 'hold_expired', 'request_expired', 'conflict_cancelled', 'user_cancelled', 'admin_cancelled', 'owner_rejected', 'balance_overdue_cancelled', name='booking_status'), nullable=False),
+ sa.Column('payment_status', sa.Enum('unpaid', 'advance_paid', 'fully_paid', 'refunded', 'partially_refunded', name='payment_status'), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('bookings')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/0005_owner_status.py b/apps/api/alembic/versions/0005_owner_status.py
new file mode 100644
index 000000000..d51e4ca18
--- /dev/null
+++ b/apps/api/alembic/versions/0005_owner_status.py
@@ -0,0 +1,92 @@
+"""owner status - add pending/rejected to profile_status enum, update signup trigger for is_owner
+
+Revision ID: 0005
+Revises: 0004
+Create Date: 2026-06-06
+
+"""
+from typing import Sequence, Union
+from alembic import op
+
+revision: str = "0005"
+down_revision: Union[str, None] = "9170dab48e33"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ALTER TYPE ADD VALUE cannot run inside a transaction in PostgreSQL
+ op.execute("COMMIT")
+ op.execute("ALTER TYPE profile_status ADD VALUE IF NOT EXISTS 'pending'")
+ op.execute("ALTER TYPE profile_status ADD VALUE IF NOT EXISTS 'rejected'")
+
+ # Update signup trigger: if is_owner=true in metadata, assign venue_owner role
+ # and set status=pending; otherwise behave as before (customer + active)
+ op.execute("""
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ SECURITY DEFINER SET search_path = public
+ AS $$
+ DECLARE
+ v_is_owner boolean;
+ BEGIN
+ v_is_owner := (NEW.raw_user_meta_data ->> 'is_owner')::boolean;
+
+ INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at)
+ VALUES (
+ NEW.id,
+ NEW.raw_user_meta_data ->> 'full_name',
+ NEW.raw_user_meta_data ->> 'phone',
+ CASE WHEN v_is_owner THEN 'pending' ELSE 'active' END,
+ now(),
+ now()
+ )
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'customer', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+
+ IF v_is_owner THEN
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'venue_owner', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+ END IF;
+
+ RETURN NEW;
+ END;
+ $$;
+ """)
+
+
+def downgrade() -> None:
+ # Restore original trigger (no is_owner handling)
+ op.execute("""
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ SECURITY DEFINER SET search_path = public
+ AS $$
+ BEGIN
+ INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at)
+ VALUES (
+ NEW.id,
+ NEW.raw_user_meta_data ->> 'full_name',
+ NEW.raw_user_meta_data ->> 'phone',
+ 'active',
+ now(),
+ now()
+ )
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'customer', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+
+ RETURN NEW;
+ END;
+ $$;
+ """)
+ # Note: removing enum values in PostgreSQL requires a full type rebuild;
+ # downgrade leaves 'pending' and 'rejected' in the enum to avoid data loss.
diff --git a/apps/api/alembic/versions/0006_expand_booking_schema.py b/apps/api/alembic/versions/0006_expand_booking_schema.py
new file mode 100644
index 000000000..1c11a5dc9
--- /dev/null
+++ b/apps/api/alembic/versions/0006_expand_booking_schema.py
@@ -0,0 +1,338 @@
+"""expand_booking_schema
+
+Revision ID: 1679aa81b5b3
+Revises: 0c70837a672c
+Create Date: 2026-06-06 13:10:37.968362
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = "1679aa81b5b3"
+down_revision: Union[str, None] = "0c70837a672c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ booking_status_enum = postgresql.ENUM(
+ "requested",
+ "owner_accepted",
+ "confirmed",
+ "completed",
+ "hold_expired",
+ "request_expired",
+ "conflict_cancelled",
+ "user_cancelled",
+ "admin_cancelled",
+ "owner_rejected",
+ "balance_overdue_cancelled",
+ name="booking_status",
+ create_type=False,
+ )
+ op.create_table(
+ "booking_slots",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("booking_id", sa.UUID(), nullable=False),
+ sa.Column("venue_id", sa.UUID(), nullable=False),
+ sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("ends_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("effective_starts_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("effective_ends_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("is_blocking", sa.Boolean(), nullable=False),
+ sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.CheckConstraint(
+ "effective_ends_at >= ends_at",
+ name="ck_booking_slots_effective_end",
+ ),
+ sa.CheckConstraint(
+ "effective_starts_at <= starts_at",
+ name="ck_booking_slots_effective_start",
+ ),
+ sa.CheckConstraint(
+ "ends_at > starts_at",
+ name="ck_booking_slots_time_order",
+ ),
+ sa.ForeignKeyConstraint(["booking_id"], ["bookings.id"]),
+ sa.ForeignKeyConstraint(["venue_id"], ["venues.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ op.create_table(
+ "booking_status_history",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("booking_id", sa.UUID(), nullable=False),
+ sa.Column(
+ "old_status",
+ booking_status_enum,
+ nullable=True,
+ ),
+ sa.Column(
+ "new_status",
+ booking_status_enum,
+ nullable=False,
+ ),
+ sa.Column("changed_by", sa.UUID(), nullable=True),
+ sa.Column("reason", sa.Text(), nullable=True),
+ sa.Column(
+ "metadata",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=True,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.ForeignKeyConstraint(["booking_id"], ["bookings.id"]),
+ sa.ForeignKeyConstraint(["changed_by"], ["profiles.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("event_type", sa.Text(), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "guest_count",
+ sa.Integer(),
+ nullable=False,
+ server_default="1",
+ ),
+ )
+
+ op.add_column("bookings", sa.Column("user_notes", sa.Text(), nullable=True))
+ op.add_column("bookings", sa.Column("owner_notes", sa.Text(), nullable=True))
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "requested_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("owner_responded_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("hold_expires_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("balance_due_date", sa.Date(), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("balance_overdue_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("owner_action_deadline", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "deadline_extension_count",
+ sa.Integer(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("pricing_mode", sa.String(), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "quoted_price_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "platform_commission_pct",
+ sa.Numeric(5, 2),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "platform_fee_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "owner_payout_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "advance_pct",
+ sa.Numeric(5, 2),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "advance_due_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "balance_due_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "overdue_advance_refund_pct",
+ sa.Numeric(5, 2),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "amount_paid_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column(
+ "refund_amount_paise",
+ sa.BigInteger(),
+ nullable=False,
+ server_default="0",
+ ),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("stripe_advance_payment_intent_id", sa.Text(), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("stripe_balance_payment_intent_id", sa.Text(), nullable=True),
+ )
+
+ op.add_column(
+ "bookings",
+ sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("bookings", "deleted_at")
+ op.drop_column("bookings", "stripe_balance_payment_intent_id")
+ op.drop_column("bookings", "stripe_advance_payment_intent_id")
+ op.drop_column("bookings", "refund_amount_paise")
+ op.drop_column("bookings", "amount_paid_paise")
+ op.drop_column("bookings", "overdue_advance_refund_pct")
+ op.drop_column("bookings", "balance_due_paise")
+ op.drop_column("bookings", "advance_due_paise")
+ op.drop_column("bookings", "advance_pct")
+ op.drop_column("bookings", "owner_payout_paise")
+ op.drop_column("bookings", "platform_fee_paise")
+ op.drop_column("bookings", "platform_commission_pct")
+ op.drop_column("bookings", "quoted_price_paise")
+ op.drop_column("bookings", "pricing_mode")
+ op.drop_column("bookings", "deadline_extension_count")
+ op.drop_column("bookings", "expired_at")
+ op.drop_column("bookings", "cancelled_at")
+ op.drop_column("bookings", "completed_at")
+ op.drop_column("bookings", "owner_action_deadline")
+ op.drop_column("bookings", "balance_overdue_at")
+ op.drop_column("bookings", "balance_due_date")
+ op.drop_column("bookings", "confirmed_at")
+ op.drop_column("bookings", "hold_expires_at")
+ op.drop_column("bookings", "owner_responded_at")
+ op.drop_column("bookings", "requested_at")
+ op.drop_column("bookings", "owner_notes")
+ op.drop_column("bookings", "user_notes")
+ op.drop_column("bookings", "guest_count")
+ op.drop_column("bookings", "event_type")
+
+ op.drop_table("booking_status_history")
+ op.drop_table("booking_slots")
diff --git a/apps/api/alembic/versions/0006_fix_owner_trigger.py b/apps/api/alembic/versions/0006_fix_owner_trigger.py
new file mode 100644
index 000000000..18b92b002
--- /dev/null
+++ b/apps/api/alembic/versions/0006_fix_owner_trigger.py
@@ -0,0 +1,112 @@
+"""fix owner trigger - ensure enum values exist, revert trigger to simple form
+
+Revision ID: 0006
+Revises: 0005
+Create Date: 2026-06-06
+
+ALTER TYPE ADD VALUE cannot run inside a transaction. We commit the current
+transaction, do the DDL, then start a new one. enum values from 0005 may
+already exist, so we check pg_enum first and only add what's missing.
+Trigger is reverted to simple form — owner role/status is set by the
+POST /api/auth/register-owner backend endpoint instead.
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = "0006"
+down_revision: Union[str, None] = "0005"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ # Check which values already exist — 0005 may have partially added them.
+ result = bind.execute(sa.text("""
+ SELECT enumlabel FROM pg_enum e
+ JOIN pg_type t ON e.enumtypid = t.oid
+ WHERE t.typname = 'profile_status'
+ """))
+ existing = {row[0] for row in result}
+
+ needs_pending = "pending" not in existing
+ needs_rejected = "rejected" not in existing
+
+ if needs_pending or needs_rejected:
+ # ALTER TYPE ADD VALUE must run outside a transaction.
+ # Commit the Alembic-managed transaction, do the DDL, restart it.
+ bind.execute(sa.text("COMMIT"))
+ if needs_pending:
+ bind.execute(sa.text("ALTER TYPE profile_status ADD VALUE 'pending'"))
+ if needs_rejected:
+ bind.execute(sa.text("ALTER TYPE profile_status ADD VALUE 'rejected'"))
+ bind.execute(sa.text("BEGIN"))
+
+ # Revert trigger to simple form — owner logic moved to backend endpoint.
+ op.execute("""
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ SECURITY DEFINER SET search_path = public
+ AS $$
+ BEGIN
+ INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at)
+ VALUES (
+ NEW.id,
+ NEW.raw_user_meta_data ->> 'full_name',
+ NEW.raw_user_meta_data ->> 'phone',
+ 'active',
+ now(),
+ now()
+ )
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'customer', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+
+ RETURN NEW;
+ END;
+ $$;
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ CREATE OR REPLACE FUNCTION public.handle_new_user()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ SECURITY DEFINER SET search_path = public
+ AS $$
+ DECLARE
+ v_is_owner boolean;
+ BEGIN
+ v_is_owner := (NEW.raw_user_meta_data ->> 'is_owner')::boolean;
+
+ INSERT INTO public.profiles (id, full_name, phone, status, created_at, updated_at)
+ VALUES (
+ NEW.id,
+ NEW.raw_user_meta_data ->> 'full_name',
+ NEW.raw_user_meta_data ->> 'phone',
+ CASE WHEN v_is_owner THEN 'pending' ELSE 'active' END,
+ now(),
+ now()
+ )
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'customer', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+
+ IF v_is_owner THEN
+ INSERT INTO public.user_roles (id, user_id, role, created_at)
+ VALUES (gen_random_uuid(), NEW.id, 'venue_owner', now())
+ ON CONFLICT (user_id, role) DO NOTHING;
+ END IF;
+
+ RETURN NEW;
+ END;
+ $$;
+ """)
diff --git a/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py b/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py
new file mode 100644
index 000000000..54e7441ff
--- /dev/null
+++ b/apps/api/alembic/versions/004b8919fa8e_remove_old_blocked_dates.py
@@ -0,0 +1,32 @@
+"""remove_old_blocked_dates
+
+Revision ID: 004b8919fa8e
+Revises: 1b6ee7242fff
+Create Date: 2026-06-25 00:13:58.128310
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '004b8919fa8e'
+down_revision: Union[str, None] = '1b6ee7242fff'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_table('blocked_dates')
+
+
+def downgrade() -> None:
+ op.create_table(
+ 'blocked_dates',
+ sa.Column('id', sa.UUID(), primary_key=True),
+ sa.Column('venue_id', sa.UUID(), sa.ForeignKey('venues.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('date', sa.Date(), nullable=False),
+ sa.Column('reason', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())
+ )
diff --git a/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py b/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py
new file mode 100644
index 000000000..02fc4595b
--- /dev/null
+++ b/apps/api/alembic/versions/014af2456033_merge_venue_pricing_and_owner_trigger_.py
@@ -0,0 +1,24 @@
+"""merge venue pricing and owner trigger branches
+
+Revision ID: 014af2456033
+Revises: 0006, 1ce66f7c8bf4
+Create Date: 2026-06-07 16:05:52.804469
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '014af2456033'
+down_revision: Union[str, None] = ('0006', '1ce66f7c8bf4')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py b/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py
new file mode 100644
index 000000000..8721cf653
--- /dev/null
+++ b/apps/api/alembic/versions/0b15a59e8154_add_guest_count_and_phone_to_lead_.py
@@ -0,0 +1,30 @@
+"""Add guest_count and phone to lead_reservations
+
+Revision ID: 0b15a59e8154
+Revises: c00a79140491
+Create Date: 2026-07-06 13:35:36.380854
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '0b15a59e8154'
+down_revision: Union[str, None] = 'c00a79140491'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('lead_reservations', sa.Column('guest_count', sa.Integer(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('phone', sa.Text(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('lead_reservations', 'phone')
+ op.drop_column('lead_reservations', 'guest_count')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py b/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py
new file mode 100644
index 000000000..b9472f3fa
--- /dev/null
+++ b/apps/api/alembic/versions/108e5e92fd6e_notification_tables_added.py
@@ -0,0 +1,135 @@
+"""Notification tables added
+
+Revision ID: 108e5e92fd6e
+Revises: bf6b28a6d1fd
+Create Date: 2026-06-11 19:12:39.902906
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '108e5e92fd6e'
+down_revision: Union[str, None] = 'bf6b28a6d1fd'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('stripe_events',
+ sa.Column('id', sa.String(), nullable=False),
+ sa.Column('type', sa.String(), nullable=False),
+ sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('processing_error', sa.Text(), nullable=True),
+ sa.Column('raw_payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('blocked_dates',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('date', sa.Date(), nullable=False),
+ sa.Column('reason', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('slots',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('date', sa.Date(), nullable=False),
+ sa.Column('is_available', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('ledger_entries',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('owner_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('entry_type', sa.String(), nullable=False),
+ sa.Column('amount_paise', sa.BigInteger(), nullable=False),
+ sa.Column('direction', sa.String(), nullable=False),
+ sa.Column('stripe_pi_ref', sa.String(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('notifications',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=True),
+ sa.Column('type', sa.String(), nullable=False),
+ sa.Column('title', sa.String(), nullable=False),
+ sa.Column('body', sa.Text(), nullable=False),
+ sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('payments',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('amount_paise', sa.BigInteger(), nullable=False),
+ sa.Column('currency', sa.String(), nullable=False),
+ sa.Column('status', sa.Enum('pending', 'succeeded', 'failed', 'refunded', name='payment_attempt_status'), nullable=False),
+ sa.Column('stripe_payment_intent_id', sa.String(), nullable=False),
+ sa.Column('stripe_client_secret', sa.String(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('payout_requests',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('owner_id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('amount_paise', sa.BigInteger(), nullable=False),
+ sa.Column('status', sa.Enum('requested', 'processing', 'paid', 'failed', name='payout_status'), nullable=False),
+ sa.Column('stripe_transfer_id', sa.String(), nullable=True),
+ sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['owner_id'], ['profiles.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('refunds',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('payment_id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('amount_paise', sa.BigInteger(), nullable=False),
+ sa.Column('reason', sa.Text(), nullable=True),
+ sa.Column('status', sa.Enum('pending', 'succeeded', 'failed', name='refund_status'), nullable=False),
+ sa.Column('stripe_refund_id', sa.String(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['payment_id'], ['payments.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('refunds')
+ op.drop_table('payout_requests')
+ op.drop_table('payments')
+ op.drop_table('notifications')
+ op.drop_table('ledger_entries')
+ op.drop_table('slots')
+ op.drop_table('blocked_dates')
+ op.drop_table('stripe_events')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py b/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py
new file mode 100644
index 000000000..c7cc3318f
--- /dev/null
+++ b/apps/api/alembic/versions/134e235b06e3_add_payment_pending_booking_status.py
@@ -0,0 +1,27 @@
+"""add_payment_pending_booking_status
+
+Revision ID: 134e235b06e3
+Revises: ab7cfebcbee6
+Create Date: 2026-07-05 15:59:21.458993
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '134e235b06e3'
+down_revision: Union[str, None] = 'ab7cfebcbee6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade():
+ op.execute("""
+ ALTER TYPE booking_status
+ ADD VALUE IF NOT EXISTS 'payment_pending';
+ """)
+
+
+def downgrade():
+ pass
diff --git a/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py b/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py
new file mode 100644
index 000000000..c7e0fb2cd
--- /dev/null
+++ b/apps/api/alembic/versions/1b6ee7242fff_merge_heads.py
@@ -0,0 +1,24 @@
+"""merge_heads
+
+Revision ID: 1b6ee7242fff
+Revises: 523613d60f75, da3ff47ffce8
+Create Date: 2026-06-25 00:13:34.563503
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '1b6ee7242fff'
+down_revision: Union[str, None] = ('523613d60f75', 'da3ff47ffce8')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/2088da544ef2_restore_booking_mode_default.py b/apps/api/alembic/versions/2088da544ef2_restore_booking_mode_default.py
new file mode 100644
index 000000000..6f8d55a0e
--- /dev/null
+++ b/apps/api/alembic/versions/2088da544ef2_restore_booking_mode_default.py
@@ -0,0 +1,27 @@
+"""restore booking_mode default
+
+Revision ID: 2088da544ef2
+Revises: 75699ec24359
+Create Date: 2026-07-07 23:06:32.632259
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '2088da544ef2'
+down_revision: Union[str, None] = '75699ec24359'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # A prior migration (75699ec24359) changed venues.booking_mode from an
+ # enum to text but dropped its DEFAULT 'MANUAL' in the process — Alembic's
+ # autogenerate doesn't reliably diff server_default, so this is manual.
+ op.alter_column('venues', 'booking_mode', server_default='MANUAL')
+
+
+def downgrade() -> None:
+ op.alter_column('venues', 'booking_mode', server_default=None)
diff --git a/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py b/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py
new file mode 100644
index 000000000..2ca7c766c
--- /dev/null
+++ b/apps/api/alembic/versions/23f89ef3a202_update_booking_status_history_.py
@@ -0,0 +1,40 @@
+"""update booking status history transition constraint
+
+Revision ID: 23f89ef3a202
+Revises: ec2964b424ff
+Create Date: 2026-06-20 13:34:34.677352
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '23f89ef3a202'
+down_revision: Union[str, None] = 'ec2964b424ff'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_constraint(
+ "ck_booking_status_history_transition", "booking_status_history", type_="check"
+ )
+
+ op.create_check_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ """
+ (old_status IS NULL) OR
+ (old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'user_cancelled', 'conflict_cancelled', 'request_expired')) OR
+ (old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled')) OR
+ (old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled')) OR
+ (old_status = 'hold_expired' AND new_status = 'owner_accepted')
+ """,
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint(
+ "ck_booking_status_history_transition", "booking_status_history", type_="check"
+ )
diff --git a/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py b/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py
new file mode 100644
index 000000000..d0ac4e5e0
--- /dev/null
+++ b/apps/api/alembic/versions/4375235671ba_add_search_metadata_columns_to_venue_.py
@@ -0,0 +1,79 @@
+"""add search metadata columns to venue_categories
+
+Revision ID: 4375235671ba
+Revises: fed3afac41e8
+Create Date: 2026-07-02 09:36:32.341225
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '4375235671ba'
+down_revision: Union[str, None] = 'fed3afac41e8'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "venue_categories",
+ sa.Column("search_boost_group", sa.Text(), nullable=True),
+ )
+ op.add_column(
+ "venue_categories",
+ sa.Column("search_keywords", sa.Text(), nullable=True),
+ )
+
+ # Backfill from the values that used to be hardcoded in
+ # category_intent.py / indexer.py / service.py, so behavior is
+ # identical immediately after migration — no silent regression in
+ # boost/keyword coverage.
+ op.execute(
+ """
+ UPDATE venue_categories
+ SET search_boost_group = 'wedding_hall_banquet_hall'
+ WHERE slug IN ('wedding_hall', 'banquet_hall')
+ """
+ )
+ op.execute(
+ """
+ UPDATE venue_categories
+ SET search_boost_group = 'event_space_rooftop_resort_lawn'
+ WHERE slug IN ('event_space', 'rooftop', 'resort', 'lawn')
+ """
+ )
+
+ keyword_backfill = {
+ "wedding_hall": (
+ "wedding marriage reception function banquet hall mandap sadya "
+ "kalyanam kalyana mandapam vivaham nikah shaadi shadi vivah "
+ "engagement muhurtham sangeet mehendi haldi baraat "
+ "wedding venue marriage hall wedding function hall"
+ ),
+ "banquet_hall": (
+ "banquet hall wedding reception marriage function party hall "
+ "conference hall corporate event convention hall"
+ ),
+ "event_space": "event space party celebration function birthday anniversary get together",
+ "rooftop": "rooftop terrace open air rooftop party sundowner",
+ "club": "club nightclub party lounge discotheque dj night",
+ "resort": "resort destination wedding luxury staycation getaway resort wedding",
+ "lawn": "lawn garden outdoor open lawn poolside function lawn",
+ "auditorium": "auditorium theatre hall seminar convocation stage",
+ }
+ conn = op.get_bind()
+ for slug, keywords in keyword_backfill.items():
+ conn.execute(
+ sa.text(
+ "UPDATE venue_categories SET search_keywords = :kw WHERE slug = :slug"
+ ),
+ {"kw": keywords, "slug": slug},
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("venue_categories", "search_keywords")
+ op.drop_column("venue_categories", "search_boost_group")
+
diff --git a/apps/api/alembic/versions/511aedccb502_merge_heads.py b/apps/api/alembic/versions/511aedccb502_merge_heads.py
new file mode 100644
index 000000000..4a672abf1
--- /dev/null
+++ b/apps/api/alembic/versions/511aedccb502_merge_heads.py
@@ -0,0 +1,24 @@
+"""merge heads
+
+Revision ID: 511aedccb502
+Revises: bf4731fd3ac6, d5464ac6f73e
+Create Date: 2026-07-07 17:23:05.391334
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '511aedccb502'
+down_revision: Union[str, None] = ('bf4731fd3ac6', 'd5464ac6f73e')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py b/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py
new file mode 100644
index 000000000..a8d200a40
--- /dev/null
+++ b/apps/api/alembic/versions/5134b9fb1f9e_deep_research_search_query_table.py
@@ -0,0 +1,36 @@
+"""deep research search query table
+
+Revision ID: 5134b9fb1f9e
+Revises: 85bed236d64b
+Create Date: 2026-07-05 13:41:00.515010
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '5134b9fb1f9e'
+down_revision: Union[str, None] = '85bed236d64b'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('deep_research_queries',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('query_text', sa.Text(), nullable=False),
+ sa.Column('city_filter', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('deep_research_queries')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py b/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py
new file mode 100644
index 000000000..a308bb640
--- /dev/null
+++ b/apps/api/alembic/versions/523613d60f75_add_last_completed_step_to_venue.py
@@ -0,0 +1,28 @@
+"""Add last_completed_step to Venue
+
+Revision ID: 523613d60f75
+Revises: 23f89ef3a202
+Create Date: 2026-06-21 20:36:07.394084
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '523613d60f75'
+down_revision: Union[str, None] = '23f89ef3a202'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('venues', sa.Column('last_completed_step', sa.Integer(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('venues', 'last_completed_step')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py b/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py
new file mode 100644
index 000000000..ba1bdccb2
--- /dev/null
+++ b/apps/api/alembic/versions/6dadfa890c15_venue_category_table.py
@@ -0,0 +1,51 @@
+"""venue category table
+
+Revision ID: 6dadfa890c15
+Revises: 108e5e92fd6e
+Create Date: 2026-06-18 22:07:44.130306
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = '6dadfa890c15'
+down_revision: Union[str, None] = '108e5e92fd6e'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('venue_categories',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('slug', sa.Text(), nullable=False),
+ sa.Column('label', sa.Text(), nullable=False),
+ sa.Column('icon', sa.Text(), nullable=True),
+ sa.Column('banner_image', sa.Text(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
+ sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('uq_venue_categories_slug_active', 'venue_categories', ['slug'], unique=True, postgresql_where=sa.text('deleted_at IS NULL'))
+ op.add_column('venues', sa.Column('category_id', sa.UUID(), nullable=True))
+ op.drop_index(op.f('idx_venues_search'), table_name='venues', postgresql_where='(deleted_at IS NULL)')
+ op.create_index('idx_venues_search', 'venues', ['city', 'category_id', 'status', 'is_active'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_foreign_key(None, 'venues', 'venue_categories', ['category_id'], ['id'])
+ op.drop_column('venues', 'venue_type')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('venues', sa.Column('venue_type', sa.TEXT(), autoincrement=False, nullable=False))
+ op.drop_constraint(None, 'venues', type_='foreignkey')
+ op.drop_index('idx_venues_search', table_name='venues', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_index(op.f('idx_venues_search'), 'venues', ['city', 'venue_type', 'status', 'is_active'], unique=False, postgresql_where='(deleted_at IS NULL)')
+ op.drop_column('venues', 'category_id')
+ op.drop_index('uq_venue_categories_slug_active', table_name='venue_categories', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.drop_table('venue_categories')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/75699ec24359_deep_research_admin_required_fields.py b/apps/api/alembic/versions/75699ec24359_deep_research_admin_required_fields.py
new file mode 100644
index 000000000..0c7c52cd4
--- /dev/null
+++ b/apps/api/alembic/versions/75699ec24359_deep_research_admin_required_fields.py
@@ -0,0 +1,62 @@
+"""deep research admin required fields
+
+Revision ID: 75699ec24359
+Revises: 511aedccb502
+Create Date: 2026-07-07 17:23:56.774694
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '75699ec24359'
+down_revision: Union[str, None] = '511aedccb502'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('lead_reservations', sa.Column('owner_id', sa.UUID(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('venue_id', sa.UUID(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('booking_id', sa.UUID(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('category_id', sa.UUID(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('contact_notes', sa.Text(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('follow_up_date', sa.Date(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('contact_method', sa.Text(), nullable=True))
+ op.add_column('lead_reservations', sa.Column('owner_invited_at', sa.DateTime(timezone=True), nullable=True))
+ op.add_column('lead_reservations', sa.Column('booking_created_at', sa.DateTime(timezone=True), nullable=True))
+ op.create_index('ix_lead_reservations_venue_id', 'lead_reservations', ['venue_id'], unique=False, postgresql_where=sa.text('venue_id IS NOT NULL'))
+ op.create_foreign_key(None, 'lead_reservations', 'venues', ['venue_id'], ['id'])
+ op.create_foreign_key(None, 'lead_reservations', 'venue_categories', ['category_id'], ['id'])
+ op.create_foreign_key(None, 'lead_reservations', 'profiles', ['owner_id'], ['id'])
+ op.create_foreign_key(None, 'lead_reservations', 'bookings', ['booking_id'], ['id'])
+ op.alter_column('venues', 'booking_mode',
+ existing_type=postgresql.ENUM('MANUAL', 'INSTANT', name='booking_mode'),
+ type_=sa.Text(),
+ existing_nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('venues', 'booking_mode',
+ existing_type=sa.Text(),
+ type_=postgresql.ENUM('MANUAL', 'INSTANT', name='booking_mode'),
+ existing_nullable=False)
+ op.drop_constraint(None, 'lead_reservations', type_='foreignkey')
+ op.drop_constraint(None, 'lead_reservations', type_='foreignkey')
+ op.drop_constraint(None, 'lead_reservations', type_='foreignkey')
+ op.drop_constraint(None, 'lead_reservations', type_='foreignkey')
+ op.drop_index('ix_lead_reservations_venue_id', table_name='lead_reservations', postgresql_where=sa.text('venue_id IS NOT NULL'))
+ op.drop_column('lead_reservations', 'booking_created_at')
+ op.drop_column('lead_reservations', 'owner_invited_at')
+ op.drop_column('lead_reservations', 'contact_method')
+ op.drop_column('lead_reservations', 'follow_up_date')
+ op.drop_column('lead_reservations', 'contact_notes')
+ op.drop_column('lead_reservations', 'category_id')
+ op.drop_column('lead_reservations', 'booking_id')
+ op.drop_column('lead_reservations', 'venue_id')
+ op.drop_column('lead_reservations', 'owner_id')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py b/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py
new file mode 100644
index 000000000..a07e48bb7
--- /dev/null
+++ b/apps/api/alembic/versions/85bed236d64b_dynamic_pricing_model_change.py
@@ -0,0 +1,66 @@
+"""dynamic pricing model change
+
+Revision ID: 85bed236d64b
+Revises: 4375235671ba
+Create Date: 2026-07-03 01:18:26.521190
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '85bed236d64b'
+down_revision: Union[str, None] = '4375235671ba'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('venue_pricing_rules',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('name', sa.Text(), nullable=False),
+ sa.Column('days_of_week', postgresql.ARRAY(sa.Integer()), nullable=True),
+ sa.Column('start_date', sa.Date(), nullable=True),
+ sa.Column('end_date', sa.Date(), nullable=True),
+ sa.Column('start_time', sa.Time(), nullable=True),
+ sa.Column('end_time', sa.Time(), nullable=True),
+ sa.Column('adjustment_type', sa.Text(), nullable=False),
+ sa.Column('multiplier', sa.Numeric(precision=5, scale=2), nullable=True),
+ sa.Column('amount_paise', sa.BigInteger(), nullable=True),
+ sa.Column('applies_to', sa.Text(), server_default=sa.text("'both'"), nullable=False),
+ sa.Column('priority', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('source', sa.Text(), server_default=sa.text("'owner'"), nullable=False),
+ sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.CheckConstraint("(adjustment_type = 'multiplier' AND multiplier IS NOT NULL AND multiplier > 0 AND amount_paise IS NULL) OR (adjustment_type = 'fixed_delta' AND amount_paise IS NOT NULL AND multiplier IS NULL) OR (adjustment_type = 'override' AND amount_paise IS NOT NULL AND amount_paise >= 0 AND multiplier IS NULL)", name='ck_venue_pricing_rules_adjustment_payload'),
+ sa.CheckConstraint("adjustment_type IN ('multiplier', 'fixed_delta', 'override')", name='ck_venue_pricing_rules_adjustment_type'),
+ sa.CheckConstraint("applies_to IN ('full_day', 'time_slot', 'both')", name='ck_venue_pricing_rules_applies_to'),
+ sa.CheckConstraint("source IN ('owner', 'system')", name='ck_venue_pricing_rules_source'),
+ sa.CheckConstraint('start_date IS NULL OR end_date IS NULL OR start_date <= end_date', name='ck_venue_pricing_rules_date_range'),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_venue_pricing_rules_priority', 'venue_pricing_rules', ['venue_id', 'priority'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND is_active = true'))
+ op.add_column('bookings', sa.Column('pricing_breakdown', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
+ op.add_column('venues', sa.Column('min_price_pct', sa.Numeric(precision=5, scale=2), server_default='50.00', nullable=False))
+ op.add_column('venues', sa.Column('max_price_pct', sa.Numeric(precision=5, scale=2), server_default='200.00', nullable=False))
+ op.add_column('venues', sa.Column('display_price_min_paise', sa.BigInteger(), nullable=True))
+ op.add_column('venues', sa.Column('display_price_max_paise', sa.BigInteger(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('venues', 'display_price_max_paise')
+ op.drop_column('venues', 'display_price_min_paise')
+ op.drop_column('venues', 'max_price_pct')
+ op.drop_column('venues', 'min_price_pct')
+ op.drop_column('bookings', 'pricing_breakdown')
+ op.drop_index('idx_venue_pricing_rules_priority', table_name='venue_pricing_rules', postgresql_where=sa.text('deleted_at IS NULL AND is_active = true'))
+ op.drop_table('venue_pricing_rules')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py b/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py
new file mode 100644
index 000000000..bde9ddbd9
--- /dev/null
+++ b/apps/api/alembic/versions/867c2871ef67_remove_slots_table.py
@@ -0,0 +1,38 @@
+"""remove_slots_table
+
+Revision ID: 867c2871ef67
+Revises: c7ff94364ee8
+Create Date: 2026-07-05 15:25:33.028368
+
+"""
+
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '867c2871ef67'
+down_revision: Union[str, None] = "c7ff94364ee8"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('slots')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('slots',
+ sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column('venue_id', sa.UUID(), autoincrement=False, nullable=False),
+ sa.Column('date', sa.DATE(), autoincrement=False, nullable=False),
+ sa.Column('is_available', sa.BOOLEAN(), autoincrement=False, nullable=False),
+ sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
+ sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], name=op.f('slots_venue_id_fkey'), ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id', name=op.f('slots_pkey'))
+ )
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py b/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py
new file mode 100644
index 000000000..9c46f644e
--- /dev/null
+++ b/apps/api/alembic/versions/914d6f7a8b9c_add_booking_constraints_and_indices.py
@@ -0,0 +1,67 @@
+"""add booking constraints and indices
+
+Revision ID: 914d6f7a8b9c
+Revises: feaba28f7d2b
+Create Date: 2026-06-09 09:20:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = '914d6f7a8b9c'
+down_revision: Union[str, None] = 'feaba28f7d2b'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # 1. Enable btree_gist extension
+ op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
+
+ # 2. Unique constraint on booking_slots(booking_id)
+ op.create_unique_constraint(
+ "uq_booking_slots_booking_id",
+ "booking_slots",
+ ["booking_id"]
+ )
+
+ # 3. Partial exclusion constraint booking_slots_no_overlap on booking_slots
+ op.execute(
+ """
+ ALTER TABLE booking_slots
+ ADD CONSTRAINT booking_slots_no_overlap
+ EXCLUDE USING gist (
+ venue_id WITH =,
+ tstzrange(effective_starts_at, effective_ends_at) WITH &&
+ ) WHERE (is_blocking = true AND deleted_at IS NULL)
+ """
+ )
+
+ # 4. Partial index idx_booking_slots_conflict_detection on booking_slots
+ op.create_index(
+ "idx_booking_slots_conflict_detection",
+ "booking_slots",
+ ["venue_id", "is_blocking", "deleted_at", "effective_starts_at", "effective_ends_at"],
+ unique=False,
+ postgresql_where=sa.text("is_blocking = true AND deleted_at IS NULL")
+ )
+
+ # 5. CHECK constraint ck_booking_status_history_transition on booking_status_history
+ op.create_check_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ "old_status IS NULL OR "
+ "old_status = new_status OR "
+ "(old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'request_expired', 'conflict_cancelled', 'admin_cancelled')) OR "
+ "(old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled', 'admin_cancelled')) OR "
+ "(old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled'))"
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint("ck_booking_status_history_transition", "booking_status_history", type_="check")
+ op.drop_index("idx_booking_slots_conflict_detection", table_name="booking_slots")
+ op.execute("ALTER TABLE booking_slots DROP CONSTRAINT booking_slots_no_overlap")
+ op.drop_constraint("uq_booking_slots_booking_id", "booking_slots", type_="unique")
diff --git a/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py b/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py
new file mode 100644
index 000000000..f717bf9f4
--- /dev/null
+++ b/apps/api/alembic/versions/9530e5e46835_add_deep_research_phase_2.py
@@ -0,0 +1,75 @@
+"""add_deep_research_phase_2
+
+Revision ID: 9530e5e46835
+Revises: 5134b9fb1f9e
+Create Date: 2026-07-05 19:15:09.813040
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = '9530e5e46835'
+down_revision: Union[str, None] = '5134b9fb1f9e'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('external_discovery_requests',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('query_id', sa.UUID(), nullable=False),
+ sa.Column('latitude', sa.Float(), nullable=False),
+ sa.Column('longitude', sa.Float(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['query_id'], ['deep_research_queries.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('query_id')
+ )
+ op.create_table('external_venue_leads',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('discovered_via_query_id', sa.UUID(), nullable=False),
+ sa.Column('source', sa.Text(), nullable=False),
+ sa.Column('source_ref', sa.Text(), nullable=False),
+ sa.Column('name', sa.Text(), nullable=False),
+ sa.Column('city', sa.Text(), nullable=True),
+ sa.Column('category_guess', sa.Text(), nullable=True),
+ sa.Column('raw_contact_info', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+ sa.Column('status', sa.Enum('DISCOVERED', 'CONTACTED', 'ONBOARDED', 'REJECTED', name='external_lead_status'), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['discovered_via_query_id'], ['deep_research_queries.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_external_venue_leads_source_ref'), 'external_venue_leads', ['source_ref'], unique=False)
+
+ op.create_table('lead_reservations',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('lead_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('status', sa.Enum('REQUESTED', 'ADMIN_CONTACTED', 'OWNER_CONFIRMED', 'DECLINED', 'EXPIRED', name='lead_reservation_status'), nullable=False),
+ sa.Column('platform_fee_paise', sa.BigInteger(), nullable=False),
+ sa.Column('event_date', sa.Date(), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['lead_id'], ['external_venue_leads.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('lead_reservations')
+ op.drop_index(op.f('ix_external_venue_leads_source_ref'), table_name='external_venue_leads')
+ op.drop_table('external_venue_leads')
+ op.drop_table('external_discovery_requests')
+
+ op.execute("DROP TYPE IF EXISTS external_lead_status")
+ op.execute("DROP TYPE IF EXISTS lead_reservation_status")
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py b/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py
new file mode 100644
index 000000000..b5bc04114
--- /dev/null
+++ b/apps/api/alembic/versions/a2b3c4d5e6f7_add_formatted_address_to_external_leads.py
@@ -0,0 +1,24 @@
+"""add_formatted_address_to_external_leads
+
+Revision ID: a2b3c4d5e6f7
+Revises: 9530e5e46835
+Create Date: 2026-07-06 11:00:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'a2b3c4d5e6f7'
+down_revision: Union[str, None] = '9530e5e46835'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('external_venue_leads', sa.Column('formatted_address', sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('external_venue_leads', 'formatted_address')
diff --git a/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py b/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py
new file mode 100644
index 000000000..baea4e7ca
--- /dev/null
+++ b/apps/api/alembic/versions/ab7cfebcbee6_add_new_fields_bookings.py
@@ -0,0 +1,33 @@
+"""add_new_fields_bookings
+
+Revision ID: ab7cfebcbee6
+Revises: c4854b7f080d
+Create Date: 2026-07-05 15:37:01.613230
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = 'ab7cfebcbee6'
+down_revision: Union[str, None] = 'c4854b7f080d'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('bookings', sa.Column('payment_expires_at', sa.DateTime(timezone=True), nullable=True))
+ op.add_column('bookings', sa.Column('auto_confirmed_at', sa.DateTime(timezone=True), nullable=True))
+ op.add_column('bookings', sa.Column('confirmed_by', sa.Text(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('bookings', 'confirmed_by')
+ op.drop_column('bookings', 'auto_confirmed_at')
+ op.drop_column('bookings', 'payment_expires_at')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py b/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py
new file mode 100644
index 000000000..e92011fd5
--- /dev/null
+++ b/apps/api/alembic/versions/adc8fe218fd7_search_index_table_venue_table_column_.py
@@ -0,0 +1,38 @@
+"""search index table + venue table column update
+
+Revision ID: adc8fe218fd7
+Revises: 004b8919fa8e
+Create Date: 2026-06-30 21:45:29.148213
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+import pgvector
+
+revision: str = 'adc8fe218fd7'
+down_revision: Union[str, None] = '004b8919fa8e'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute('CREATE EXTENSION IF NOT EXISTS vector')
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('bookings', 'payment_mode')
+ op.add_column('venues', sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True))
+ op.add_column('venues', sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True))
+ op.add_column('venues', sa.Column('embedding_updated_at', sa.DateTime(timezone=True), nullable=True))
+ op.drop_column('venues', 'payment_mode')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('venues', sa.Column('payment_mode', sa.TEXT(), server_default=sa.text("'advance_balance'::text"), autoincrement=False, nullable=False))
+ op.drop_column('venues', 'embedding_updated_at')
+ op.drop_column('venues', 'embedding')
+ op.drop_column('venues', 'search_vector')
+ op.add_column('bookings', sa.Column('payment_mode', sa.VARCHAR(), server_default=sa.text("'advance_balance'::character varying"), autoincrement=False, nullable=False))
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py b/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py
new file mode 100644
index 000000000..abbdfc6dc
--- /dev/null
+++ b/apps/api/alembic/versions/b3c4d5e6f7a8_add_cover_photo_url_to_external_leads.py
@@ -0,0 +1,25 @@
+"""add_cover_photo_url_to_external_leads
+
+Revision ID: b3c4d5e6f7a8
+Revises: a2b3c4d5e6f7
+Create Date: 2026-07-06 12:22:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'b3c4d5e6f7a8'
+down_revision: Union[str, None] = 'a2b3c4d5e6f7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Cloudinary URL — null until the background job successfully uploads the photo.
+ op.add_column('external_venue_leads', sa.Column('cover_photo_url', sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('external_venue_leads', 'cover_photo_url')
diff --git a/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py b/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py
new file mode 100644
index 000000000..6d0a3a100
--- /dev/null
+++ b/apps/api/alembic/versions/bf4731fd3ac6_update_booking_status_history_constraint.py
@@ -0,0 +1,125 @@
+"""update_booking_status_history_constraint
+
+Revision ID: bf4731fd3ac6
+Revises: 134e235b06e3
+Create Date: 2026-07-05 16:06:07.393205
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = 'bf4731fd3ac6'
+down_revision: Union[str, None] = '134e235b06e3'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+CHECK_CONSTRAINT = """
+(old_status IS NULL)
+
+OR
+
+(old_status = 'requested'
+ AND new_status IN (
+ 'owner_accepted',
+ 'owner_rejected',
+ 'request_expired',
+ 'conflict_cancelled',
+ 'admin_cancelled',
+ 'user_cancelled'
+ ))
+
+OR
+
+(old_status = 'payment_pending'
+ AND new_status IN (
+ 'confirmed',
+ 'hold_expired',
+ 'user_cancelled',
+ 'admin_cancelled',
+ 'conflict_cancelled'
+ ))
+
+OR
+
+(old_status = 'owner_accepted'
+ AND new_status IN (
+ 'confirmed',
+ 'hold_expired',
+ 'user_cancelled',
+ 'admin_cancelled'
+ ))
+
+OR
+
+(old_status = 'confirmed'
+ AND new_status IN (
+ 'completed',
+ 'user_cancelled',
+ 'admin_cancelled',
+ 'balance_overdue_cancelled'
+ ))
+"""
+
+
+def upgrade() -> None:
+ op.drop_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ type_="check",
+ )
+
+ op.create_check_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ CHECK_CONSTRAINT,
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ type_="check",
+ )
+
+ op.create_check_constraint(
+ "ck_booking_status_history_transition",
+ "booking_status_history",
+ """
+ (old_status IS NULL)
+
+ OR
+
+ (old_status = 'requested'
+ AND new_status IN (
+ 'owner_accepted',
+ 'owner_rejected',
+ 'request_expired',
+ 'conflict_cancelled',
+ 'admin_cancelled'
+ ))
+
+ OR
+
+ (old_status = 'owner_accepted'
+ AND new_status IN (
+ 'confirmed',
+ 'hold_expired',
+ 'user_cancelled',
+ 'admin_cancelled'
+ ))
+
+ OR
+
+ (old_status = 'confirmed'
+ AND new_status IN (
+ 'completed',
+ 'user_cancelled',
+ 'admin_cancelled',
+ 'balance_overdue_cancelled'
+ ))
+ """,
+ )
diff --git a/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py b/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py
new file mode 100644
index 000000000..317b03012
--- /dev/null
+++ b/apps/api/alembic/versions/bf6b28a6d1fd_amenity_name_uniqueness_partial_index_.py
@@ -0,0 +1,30 @@
+"""amenity name uniqueness: partial index excluding soft-deleted rows
+
+Revision ID: bf6b28a6d1fd
+Revises: 914d6f7a8b9c
+Create Date: 2026-06-09 18:47:52.686325
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'bf6b28a6d1fd'
+down_revision: Union[str, None] = '914d6f7a8b9c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint(op.f('amenities_name_key'), 'amenities', type_='unique')
+ op.create_index('uq_amenities_name_active', 'amenities', ['name'], unique=True, postgresql_where=sa.text('deleted_at IS NULL'))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('uq_amenities_name_active', table_name='amenities', postgresql_where=sa.text('deleted_at IS NULL'))
+ op.create_unique_constraint(op.f('amenities_name_key'), 'amenities', ['name'], postgresql_nulls_not_distinct=False)
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py b/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py
new file mode 100644
index 000000000..a20ccfe4c
--- /dev/null
+++ b/apps/api/alembic/versions/c00a79140491_update_leadreservationstatus_enum.py
@@ -0,0 +1,37 @@
+"""Update LeadReservationStatus enum
+
+Revision ID: c00a79140491
+Revises: d1e2f3a4b5c6
+Create Date: 2026-07-06 13:24:27.472285
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+
+
+revision: str = 'c00a79140491'
+down_revision: Union[str, None] = 'd1e2f3a4b5c6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # 1. Rename existing enum
+ op.execute("ALTER TYPE lead_reservation_status RENAME TO lead_reservation_status_old")
+
+ # 2. Create new enum
+ op.execute("CREATE TYPE lead_reservation_status AS ENUM('new', 'contacted', 'owner_interested', 'owner_invited', 'owner_onboarded', 'venue_draft_created', 'venue_pending_approval', 'venue_approved', 'booking_created', 'closed', 'cancelled', 'rejected')")
+
+ # 3. Alter table to use varchar temporarily, map data, then cast to new enum
+ op.execute("ALTER TABLE lead_reservations ALTER COLUMN status TYPE VARCHAR USING status::text")
+ op.execute("UPDATE lead_reservations SET status = 'new' WHERE status = 'requested'")
+ op.execute("ALTER TABLE lead_reservations ALTER COLUMN status TYPE lead_reservation_status USING status::lead_reservation_status")
+
+ # 4. Drop old enum
+ op.execute("DROP TYPE lead_reservation_status_old")
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py b/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py
new file mode 100644
index 000000000..5c7f4c352
--- /dev/null
+++ b/apps/api/alembic/versions/c4854b7f080d_add_booking_mode_venue.py
@@ -0,0 +1,48 @@
+"""add_booking_mode_venue
+
+Revision ID: c4854b7f080d
+Revises: 867c2871ef67
+Create Date: 2026-07-05 15:30:56.305238
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'c4854b7f080d'
+down_revision: Union[str, None] = '867c2871ef67'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+booking_mode_enum = sa.Enum(
+ "MANUAL",
+ "INSTANT",
+ name="booking_mode",
+)
+
+
+def upgrade():
+ booking_mode_enum.create(op.get_bind(), checkfirst=True)
+
+ op.add_column(
+ "venues",
+ sa.Column(
+ "booking_mode",
+ booking_mode_enum,
+ nullable=False,
+ server_default="MANUAL",
+ ),
+ )
+
+ op.alter_column(
+ "venues",
+ "booking_mode",
+ server_default=None,
+ )
+
+
+def downgrade():
+ op.drop_column("venues", "booking_mode")
+ booking_mode_enum.drop(op.get_bind(), checkfirst=True)
diff --git a/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py b/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py
new file mode 100644
index 000000000..d74473360
--- /dev/null
+++ b/apps/api/alembic/versions/c7ff94364ee8_deep_research_logs_field_update.py
@@ -0,0 +1,33 @@
+"""deep research logs field update
+
+Revision ID: c7ff94364ee8
+Revises: 9530e5e46835
+Create Date: 2026-07-06 00:41:16.979095
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = 'c7ff94364ee8'
+down_revision: Union[str, None] = '9530e5e46835'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('deep_research_queries', sa.Column('understanding_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
+ op.add_column('deep_research_queries', sa.Column('result_count', sa.Integer(), server_default='0', nullable=False))
+ op.add_column('deep_research_queries', sa.Column('avg_match_score', sa.Float(), nullable=True))
+ op.add_column('deep_research_queries', sa.Column('top_results_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('deep_research_queries', 'top_results_json')
+ op.drop_column('deep_research_queries', 'avg_match_score')
+ op.drop_column('deep_research_queries', 'result_count')
+ op.drop_column('deep_research_queries', 'understanding_json')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py b/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py
new file mode 100644
index 000000000..8251b98e0
--- /dev/null
+++ b/apps/api/alembic/versions/d1e2f3a4b5c6_merge_deep_research_branches.py
@@ -0,0 +1,23 @@
+"""merge_deep_research_branches
+
+Revision ID: d1e2f3a4b5c6
+Revises: b3c4d5e6f7a8, c7ff94364ee8
+Create Date: 2026-07-06 12:54:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+
+
+revision: str = 'd1e2f3a4b5c6'
+down_revision: Union[str, Sequence[str], None] = ('b3c4d5e6f7a8', 'c7ff94364ee8')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass # Merge only — no schema changes
+
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py b/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py
new file mode 100644
index 000000000..67a7ce3e5
--- /dev/null
+++ b/apps/api/alembic/versions/d5464ac6f73e_add_venue_likes_table_v2.py
@@ -0,0 +1,38 @@
+"""Add venue_likes table v2
+
+Revision ID: d5464ac6f73e
+Revises: 0b15a59e8154
+Create Date: 2026-07-06 14:39:36.384314
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+revision: str = 'd5464ac6f73e'
+down_revision: Union[str, None] = '0b15a59e8154'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('venue_likes',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('uq_venue_likes_user_venue', 'venue_likes', ['user_id', 'venue_id'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('uq_venue_likes_user_venue', table_name='venue_likes')
+ op.drop_table('venue_likes')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/da3960835584_booking_invoices_table.py b/apps/api/alembic/versions/da3960835584_booking_invoices_table.py
new file mode 100644
index 000000000..4bb5c84a5
--- /dev/null
+++ b/apps/api/alembic/versions/da3960835584_booking_invoices_table.py
@@ -0,0 +1,36 @@
+"""booking invoices table
+
+Revision ID: da3960835584
+Revises: 2088da544ef2
+Create Date: 2026-07-08 00:00:00.000000
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'da3960835584'
+down_revision: Union[str, None] = '2088da544ef2'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('booking_invoices',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('status', sa.Text(), server_default='pending', nullable=False),
+ sa.Column('pdf_url', sa.Text(), nullable=True),
+ sa.Column('attempts', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('error_message', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('booking_id'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('booking_invoices')
diff --git a/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py b/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py
new file mode 100644
index 000000000..f047599c6
--- /dev/null
+++ b/apps/api/alembic/versions/da3ff47ffce8_bugs_and_balance_payment_refactor.py
@@ -0,0 +1,32 @@
+"""Bugs and Balance payment refactor
+
+Revision ID: da3ff47ffce8
+Revises: ec2964b424ff
+Create Date: 2026-06-21 10:00:05.418688
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'da3ff47ffce8'
+down_revision: Union[str, None] = 'ec2964b424ff'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('bookings', sa.Column('payment_mode', sa.String(), server_default='advance_balance', nullable=False))
+ op.add_column('payments', sa.Column('payment_type', sa.String(), server_default='advance', nullable=False))
+ op.add_column('venues', sa.Column('payment_mode', sa.Text(), server_default='advance_balance', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('venues', 'payment_mode')
+ op.drop_column('payments', 'payment_type')
+ op.drop_column('bookings', 'payment_mode')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/dfe745817ac5_add_ratings_venue_table.py b/apps/api/alembic/versions/dfe745817ac5_add_ratings_venue_table.py
new file mode 100644
index 000000000..730cebbb8
--- /dev/null
+++ b/apps/api/alembic/versions/dfe745817ac5_add_ratings_venue_table.py
@@ -0,0 +1,59 @@
+"""add_ratings_venue_table
+
+Revision ID: dfe745817ac5
+Revises: da3960835584
+Create Date: 2026-07-09 03:23:39.382732
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'dfe745817ac5'
+down_revision: Union[str, None] = 'da3960835584'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('venue_reviews',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('venue_id', sa.UUID(), nullable=False),
+ sa.Column('booking_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('rating', sa.Integer(), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=True),
+ sa.Column('comment', sa.Text(), nullable=False),
+ sa.Column('is_hidden', sa.Boolean(), nullable=False),
+ sa.Column('hidden_reason', sa.String(length=255), nullable=True),
+ sa.Column('hidden_by', sa.UUID(), nullable=True),
+ sa.Column('hidden_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['hidden_by'], ['profiles.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['profiles.id'], ),
+ sa.ForeignKeyConstraint(['venue_id'], ['venues.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('booking_id')
+ )
+ op.create_index(op.f('ix_venue_reviews_created_at'), 'venue_reviews', ['created_at'], unique=False)
+ op.create_index(op.f('ix_venue_reviews_deleted_at'), 'venue_reviews', ['deleted_at'], unique=False)
+ op.create_index(op.f('ix_venue_reviews_is_hidden'), 'venue_reviews', ['is_hidden'], unique=False)
+ op.create_index(op.f('ix_venue_reviews_user_id'), 'venue_reviews', ['user_id'], unique=False)
+ op.create_index(op.f('ix_venue_reviews_venue_id'), 'venue_reviews', ['venue_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_venue_reviews_venue_id'), table_name='venue_reviews')
+ op.drop_index(op.f('ix_venue_reviews_user_id'), table_name='venue_reviews')
+ op.drop_index(op.f('ix_venue_reviews_is_hidden'), table_name='venue_reviews')
+ op.drop_index(op.f('ix_venue_reviews_deleted_at'), table_name='venue_reviews')
+ op.drop_index(op.f('ix_venue_reviews_created_at'), table_name='venue_reviews')
+ op.drop_table('venue_reviews')
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py b/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py
new file mode 100644
index 000000000..a9fd8a478
--- /dev/null
+++ b/apps/api/alembic/versions/ec2964b424ff_venue_category_id_not_null.py
@@ -0,0 +1,32 @@
+"""venue category_id not null
+
+Revision ID: ec2964b424ff
+Revises: 6dadfa890c15
+Create Date: 2026-06-18 22:41:20.323502
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'ec2964b424ff'
+down_revision: Union[str, None] = '6dadfa890c15'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('venues', 'category_id',
+ existing_type=sa.UUID(),
+ nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('venues', 'category_id',
+ existing_type=sa.UUID(),
+ nullable=True)
+ # ### end Alembic commands ###
diff --git a/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py b/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py
new file mode 100644
index 000000000..2dee3dbc6
--- /dev/null
+++ b/apps/api/alembic/versions/feaba28f7d2b_merge_booking_and_venue_branches.py
@@ -0,0 +1,24 @@
+"""Merge booking and venue branches - no schema changes needed
+
+Revision ID: feaba28f7d2b
+Revises: 1679aa81b5b3, 014af2456033
+Create Date: 2026-06-08 23:22:56.136502
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'feaba28f7d2b'
+down_revision: Union[str, None] = ('1679aa81b5b3', '014af2456033')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/apps/api/alembic/versions/fed3afac41e8_search_index_table.py b/apps/api/alembic/versions/fed3afac41e8_search_index_table.py
new file mode 100644
index 000000000..921a8d2bd
--- /dev/null
+++ b/apps/api/alembic/versions/fed3afac41e8_search_index_table.py
@@ -0,0 +1,40 @@
+"""search index table
+
+Revision ID: fed3afac41e8
+Revises: adc8fe218fd7
+Create Date: 2026-06-30 21:49:51.958140
+
+"""
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'fed3afac41e8'
+down_revision: Union[str, None] = 'adc8fe218fd7'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('search_index_jobs',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('entity_type', sa.Text(), nullable=False),
+ sa.Column('entity_id', sa.UUID(), nullable=False),
+ sa.Column('operation', sa.Text(), nullable=False),
+ sa.Column('status', sa.Text(), server_default='pending', nullable=False),
+ sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False),
+ sa.Column('error_message', sa.Text(), nullable=True),
+ sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('search_index_jobs')
+ # ### end Alembic commands ###
diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py
new file mode 100644
index 000000000..6fc4b5263
--- /dev/null
+++ b/apps/api/app/core/config.py
@@ -0,0 +1,98 @@
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+ database_url: str
+ supabase_url: str
+ supabase_jwt_secret: str
+ supabase_service_role_key: str
+ stripe_secret_key: str = ""
+ stripe_webhook_secret: str = ""
+ stripe_currency: str = "inr"
+
+ # Email — Resend is primary, SMTP is the fallback transport
+ resend_api_key: str = ""
+ email_from: str = "Venue404 "
+ smtp_host: str = ""
+ smtp_port: int = 587
+ smtp_user: str = ""
+ smtp_password: str = ""
+
+ # Used to build deep links inside notification emails
+ frontend_base_url: str = "https://venue404-user-web-git-main-venue123.vercel.app"
+
+ # Used to build the Supabase invite redirect for admin-invited venue owners
+ owner_portal_base_url: str = "https://venue404-owner-portal-git-main-venue123.vercel.app"
+
+ # Comma-separated list of allowed browser origins for CORS. Defaults to the
+ # local dev ports; in production set this to the deployed Vercel app URLs.
+ cors_origins: str = ("https://venue404-owner-portal-git-main-venue123.vercel.app,https://venue404-user-web-git-main-venue123.vercel.app,https://venue404-admin-panel-git-main-venue123.vercel.app,http://localhost:5397,http://localhost:5398,http://localhost:5399"
+ )
+
+ # Shared secret guarding the machine-to-machine job-runner endpoint. Empty
+ # disables the endpoint (returns 503). Set to a long random value in prod.
+ job_runner_token: str = ""
+
+ @property
+ def cors_origins_list(self) -> list[str]:
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
+
+ # Booking economics (percent of venue price)
+ token_advance_pct: int = 20
+ platform_fee_pct: int = 5
+
+ # Only the process that owns scheduling should flip this on
+ enable_jobs: bool = False
+
+ super_admin_name: str = ""
+ super_admin_email: str = ""
+ super_admin_password: str = ""
+ cloudinary_cloud_name: str = ""
+ cloudinary_api_key: str = ""
+ cloudinary_api_secret: str = ""
+
+ # Upstash Redis — used as a fast delivery channel for search index jobs.
+ # If unset, the worker falls back to polling search_index_jobs directly.
+ upstash_redis_url: str = ""
+ upstash_redis_token: str = ""
+ upstash_search_queue_key: str = "search_index_jobs"
+ upstash_invoice_queue_key: str = "booking_invoice_jobs"
+
+ # Jina AI — used to generate venue embeddings for semantic search.
+ jina_api_key: str = ""
+ jina_embedding_model: str = "jina-embeddings-v3"
+ embedding_dimensions: int = 1024
+
+ search_min_vector_similarity: float = 0.15
+ search_wedding_boost: float = 1.85
+ search_event_boost: float = 1.40
+ search_fts_weight: float = 0.3
+ search_corporate_boost: float = 1.40
+ search_vector_weight: float = 0.7
+ search_diagnostics_enabled: bool = False
+
+ search_normalizer_match_threshold: int = 85
+ search_normalizer_min_token_len: int = 3
+
+ # Groq — used for Deep Research's query-understanding stage (OpenAI-compatible API).
+ groq_api_key: str = ""
+ groq_model: str = "llama-3.1-8b-instant"
+ groq_base_url: str = "https://api.groq.com/openai/v1"
+
+ # Google Places API Key
+ google_places_api_key: str = ""
+
+ # Deep Research rate limiting — protects the Groq / Google Places /
+ # Cloudinary calls behind /search and /external from burst abuse and
+ # caps the per-user daily cost. Backed by Upstash Redis; if Upstash isn't
+ # configured, limiting is skipped (fails open, matching indexer.py).
+ deep_research_rate_limit_per_minute: int = 5
+ deep_research_daily_limit: int = 4
+
+ log_level: str = "INFO" # DEBUG / INFO / WARNING / ERROR
+
+ class Config:
+ env_file = ".env"
+
+
+settings = Settings()
diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py
new file mode 100644
index 000000000..dfd24cc74
--- /dev/null
+++ b/apps/api/app/core/database.py
@@ -0,0 +1,42 @@
+from contextlib import contextmanager
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session
+from app.core.config import settings
+
+engine = create_engine(settings.database_url)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+def get_db():
+ """FastAPI request-scoped session dependency."""
+ db = SessionLocal()
+ try:
+ yield db
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+
+@contextmanager
+def with_session() -> Session:
+ """Session for code that runs outside a request (jobs, webhooks).
+
+ Commits on success, rolls back on exception, always closes. Use this for
+ transactional work like payment confirmation that needs explicit control.
+ """
+ db = SessionLocal()
+ try:
+ yield db
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise
+ finally:
+ db.close()
diff --git a/apps/api/app/core/email.py b/apps/api/app/core/email.py
new file mode 100644
index 000000000..67078561c
--- /dev/null
+++ b/apps/api/app/core/email.py
@@ -0,0 +1,66 @@
+"""Email transport: Resend primary, SMTP fallback.
+
+`send_email` picks a transport at call time:
+ 1. RESEND_API_KEY set -> send via Resend
+ 2. else SMTP_HOST set -> send via SMTP
+ 3. else (dev, nothing set) -> log and no-op (never raises in dev)
+
+Returns True if an email was actually dispatched, False if it was a dev no-op.
+"""
+import logging
+import smtplib
+from email.message import EmailMessage
+from email.utils import formatdate, make_msgid
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def send_email(to: str, subject: str, html: str) -> bool:
+ if settings.resend_api_key:
+ return _send_via_resend(to, subject, html)
+ if settings.smtp_host:
+ return _send_via_smtp(to, subject, html)
+ logger.warning(
+ "No email transport configured (RESEND_API_KEY / SMTP_HOST unset); "
+ "skipping email to %s: %r",
+ to,
+ subject,
+ )
+ return False
+
+
+def _send_via_resend(to: str, subject: str, html: str) -> bool:
+ import resend
+
+ resend.api_key = settings.resend_api_key
+ resend.Emails.send(
+ {
+ "from": settings.email_from,
+ "to": [to],
+ "subject": subject,
+ "html": html,
+ }
+ )
+ logger.info("Sent email via Resend to %s: %r", to, subject)
+ return True
+
+
+def _send_via_smtp(to: str, subject: str, html: str) -> bool:
+ msg = EmailMessage()
+ msg["From"] = settings.email_from
+ msg["To"] = to
+ msg["Subject"] = subject
+ msg["Date"] = formatdate(localtime=True)
+ msg["Message-ID"] = make_msgid(domain=settings.smtp_host)
+ msg.set_content("This email requires an HTML-capable client.")
+ msg.add_alternative(html, subtype="html")
+
+ with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
+ server.starttls()
+ if settings.smtp_user:
+ server.login(settings.smtp_user, settings.smtp_password)
+ server.send_message(msg)
+ logger.info("Sent email via SMTP to %s: %r", to, subject)
+ return True
diff --git a/apps/api/app/core/exceptions.py b/apps/api/app/core/exceptions.py
new file mode 100644
index 000000000..cf4ede3ec
--- /dev/null
+++ b/apps/api/app/core/exceptions.py
@@ -0,0 +1,35 @@
+from fastapi import HTTPException, status
+
+
+class NotFoundError(HTTPException):
+ def __init__(self, detail: str = "Not found"):
+ super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
+
+
+class UnauthorizedError(HTTPException):
+ def __init__(self, detail: str = "Unauthorized"):
+ super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
+
+
+class ForbiddenError(HTTPException):
+ def __init__(self, detail: str = "Forbidden"):
+ super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
+
+
+class ConflictError(HTTPException):
+ def __init__(self, detail: str = "Conflict"):
+ super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
+
+
+class BadRequestError(HTTPException):
+ def __init__(self, detail: str = "Bad request"):
+ super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
+
+
+class RateLimitError(HTTPException):
+ def __init__(self, detail: str = "Too many requests"):
+ super().__init__(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail)
+
+class APIException(HTTPException):
+ def __init__(self, status_code: int = 500, detail: str = "Internal server error"):
+ super().__init__(status_code=status_code, detail=detail)
\ No newline at end of file
diff --git a/apps/api/app/core/job_queue.py b/apps/api/app/core/job_queue.py
new file mode 100644
index 000000000..3f28a4bca
--- /dev/null
+++ b/apps/api/app/core/job_queue.py
@@ -0,0 +1,51 @@
+"""Generic Upstash-backed durable job queue mechanics, shared by every async
+job pipeline in this app (search indexing, invoice generation, ...).
+
+Each pipeline still owns its own DB model, its own SQLAlchemy queries for
+"pending" and "failed" rows, and its own processing function — this module
+only factors out the two bits that were duplicated identically across them:
+dequeuing from the Upstash fast-path (with DB-polling as the fallback), and
+deciding whether a failed row's backoff window has elapsed yet.
+"""
+import logging
+from datetime import datetime, timezone, timedelta
+
+from app.core import redis as redis_client
+
+logger = logging.getLogger(__name__)
+
+# Attempt 0 -> immediate, 1 -> 5 min, 2 -> 15 min, 3 -> 1 hr, 4 -> 6 hr
+DEFAULT_BACKOFF_SECONDS = [0, 300, 900, 3600, 21600]
+
+
+def dequeue_from_redis(queue_key: str, limit: int) -> list[str]:
+ """Pop up to `limit` ids from an Upstash list queue.
+
+ Returns [] if Upstash isn't configured or is unreachable — the caller
+ falls back to polling the DB directly (see is_backoff_eligible).
+ """
+ if not redis_client.is_configured():
+ return []
+
+ try:
+ redis = redis_client.get_redis()
+ ids = []
+ for _ in range(limit):
+ item = redis.rpop(queue_key)
+ if not item:
+ break
+ ids.append(item)
+ return ids
+ except Exception as exc:
+ logger.warning("Redis unavailable: %s", exc)
+ return []
+
+
+def is_backoff_eligible(
+ created_at: datetime, attempts: int, backoff_seconds: list[int] = DEFAULT_BACKOFF_SECONDS,
+) -> bool:
+ """Whether a failed row created at `created_at` with `attempts` prior
+ tries has waited long enough per the backoff schedule to retry now."""
+ delay = backoff_seconds[min(attempts, len(backoff_seconds) - 1)]
+ eligible_at = created_at.replace(tzinfo=timezone.utc) + timedelta(seconds=delay)
+ return datetime.now(timezone.utc) >= eligible_at
diff --git a/apps/api/app/core/logging.py b/apps/api/app/core/logging.py
new file mode 100644
index 000000000..1169efb56
--- /dev/null
+++ b/apps/api/app/core/logging.py
@@ -0,0 +1,35 @@
+import logging
+import sys
+from app.core.config import settings
+
+
+def setup_logging() -> None:
+ """Configure root logger for console output (critical for Docker)."""
+ level = getattr(logging, settings.log_level.upper(), logging.INFO)
+
+ root = logging.getLogger()
+ root.setLevel(level)
+
+ # Console handler (stdout → captured by Docker)
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setLevel(level)
+
+ formatter = logging.Formatter(
+ fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ handler.setFormatter(formatter)
+
+ # Avoid duplicate handlers
+ if not root.handlers:
+ root.addHandler(handler)
+
+ # Quiet noisy libraries
+ logging.getLogger("uvicorn").setLevel(logging.INFO)
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+ logging.getLogger("stripe").setLevel(logging.WARNING)
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+
+ logging.getLogger(__name__).info(
+ "✅ Logging initialized at level: %s", settings.log_level
+ )
diff --git a/apps/api/app/core/middleware.py b/apps/api/app/core/middleware.py
new file mode 100644
index 000000000..3384ba29c
--- /dev/null
+++ b/apps/api/app/core/middleware.py
@@ -0,0 +1,14 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.core.config import settings
+
+
+def register_middleware(app: FastAPI) -> None:
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins_list,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
diff --git a/apps/api/app/core/rate_limit.py b/apps/api/app/core/rate_limit.py
new file mode 100644
index 000000000..b3d1a421e
--- /dev/null
+++ b/apps/api/app/core/rate_limit.py
@@ -0,0 +1,51 @@
+"""Upstash-Redis-backed rate limiting.
+
+Fixed-window counters keyed per user/action/window. If Upstash isn't
+configured, checks fail open (no limiting) — matches the fallback pattern
+already used by the search indexer's job queue push.
+"""
+import time
+from datetime import date
+from uuid import UUID
+
+from app.core import redis as redis_client
+from app.core.config import settings
+from app.core.exceptions import RateLimitError
+
+
+def _check(key: str, limit: int, ttl_seconds: int, detail: str) -> None:
+ if not redis_client.is_configured():
+ return
+ try:
+ client = redis_client.get_redis()
+ count = client.incr(key)
+ if count == 1:
+ client.expire(key, ttl_seconds)
+ except RateLimitError:
+ raise
+ except Exception:
+ # Redis unreachable — fail open rather than blocking the feature.
+ return
+ if count > limit:
+ raise RateLimitError(detail)
+
+
+def enforce_per_minute_limit(user_id: UUID, action: str) -> None:
+ window = int(time.time() // 60)
+ key = f"rl:{action}:min:{user_id}:{window}"
+ _check(
+ key,
+ settings.deep_research_rate_limit_per_minute,
+ ttl_seconds=60,
+ detail="Too many requests — please slow down and try again shortly.",
+ )
+
+
+def enforce_daily_limit(user_id: UUID, action: str, limit: int) -> None:
+ key = f"rl:{action}:day:{user_id}:{date.today().isoformat()}"
+ _check(
+ key,
+ limit,
+ ttl_seconds=90_000, # 25h, covers clock drift across the day boundary
+ detail=f"Daily limit of {limit} deep research requests reached — try again tomorrow.",
+ )
diff --git a/apps/api/app/core/redis.py b/apps/api/app/core/redis.py
new file mode 100644
index 000000000..7ba05e8ea
--- /dev/null
+++ b/apps/api/app/core/redis.py
@@ -0,0 +1,17 @@
+"""Used by rate limiting, the search index job queue, and Deep Research
+query caching.
+
+Upstash is optional infra: every caller must treat it as fail-open (check
+is_configured()/catch exceptions) rather than assume it's always reachable.
+"""
+from app.core.config import settings
+
+
+def is_configured() -> bool:
+ return bool(settings.upstash_redis_url and settings.upstash_redis_token)
+
+
+def get_redis():
+ from upstash_redis import Redis
+
+ return Redis(url=settings.upstash_redis_url, token=settings.upstash_redis_token)
diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py
new file mode 100644
index 000000000..c407f2520
--- /dev/null
+++ b/apps/api/app/core/storage.py
@@ -0,0 +1,60 @@
+import io
+import cloudinary
+import cloudinary.uploader
+from fastapi import HTTPException, status
+from PIL import Image
+from app.core.config import settings
+
+_MAX_DIMENSION = 2048
+_JPEG_QUALITY = 85
+
+
+def compress_image(file_bytes: bytes) -> bytes:
+ img = Image.open(io.BytesIO(file_bytes))
+ exif = img.info.get("exif", b"")
+ if img.mode in ("RGBA", "P"):
+ img = img.convert("RGB")
+ img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION), Image.LANCZOS)
+ out = io.BytesIO()
+ img.save(out, format="WEBP", quality=_JPEG_QUALITY, method=6, exif=exif)
+ return out.getvalue()
+
+
+if settings.cloudinary_cloud_name and settings.cloudinary_api_key and settings.cloudinary_api_secret:
+ cloudinary.config(
+ cloud_name=settings.cloudinary_cloud_name,
+ api_key=settings.cloudinary_api_key,
+ api_secret=settings.cloudinary_api_secret,
+ secure=True,
+ )
+
+def upload_image_to_cloudinary(file_bytes: bytes, folder: str = "venues") -> str:
+
+ if not settings.cloudinary_cloud_name:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Cloudinary is not configured on the server."
+ )
+
+ try:
+ file_bytes = compress_image(file_bytes)
+ response = cloudinary.uploader.upload(
+ file_bytes,
+ folder=folder,
+ resource_type="image"
+ )
+ return response.get("secure_url")
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Image upload failed: {str(e)}"
+ )
+
+def delete_image_from_cloudinary(public_id: str):
+
+ if not settings.cloudinary_cloud_name:
+ return
+ try:
+ cloudinary.uploader.destroy(public_id)
+ except Exception:
+ pass
\ No newline at end of file
diff --git a/apps/api/app/core/stripe_client.py b/apps/api/app/core/stripe_client.py
new file mode 100644
index 000000000..6c6079824
--- /dev/null
+++ b/apps/api/app/core/stripe_client.py
@@ -0,0 +1,13 @@
+"""Single configured Stripe client.
+
+Importing `stripe` from here guarantees the API key is set once, and gives tests
+a single place to monkeypatch the SDK.
+"""
+import stripe
+from app.core.config import settings
+
+stripe.api_key = settings.stripe_secret_key
+
+
+def get_stripe():
+ return stripe
diff --git a/apps/api/app/infrastructure/__init__.py b/apps/api/app/infrastructure/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/infrastructure/embeddings/jina.py b/apps/api/app/infrastructure/embeddings/jina.py
new file mode 100644
index 000000000..95339b830
--- /dev/null
+++ b/apps/api/app/infrastructure/embeddings/jina.py
@@ -0,0 +1,147 @@
+"""Jina AI embeddings client.
+
+This module is the only place that knows about Jina's API URL, auth headers,
+request/response shape, retry policy, and task-type strings. Callers (e.g.
+app.modules.search.indexer) should only ever import embed_passage /
+embed_query / embed_texts from here — they should never need to know this
+is Jina, or what the HTTP contract looks like.
+"""
+
+import logging
+import time
+from enum import Enum
+
+import httpx
+import numpy as np
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+_EMBEDDINGS_URL = "https://api.jina.ai/v1/embeddings"
+
+# Retry policy for transient failures (network errors, 5xx, 429).
+_MAX_RETRIES = 3
+_RETRY_BACKOFF_SECONDS = [1, 3, 8]
+
+# Jina caps input array size per request; batch larger inputs to stay under it.
+_MAX_BATCH_SIZE = 64
+
+
+class EmbeddingTask(str, Enum):
+ """Jina task-type strings. Passage = documents being indexed, query = search input."""
+
+ PASSAGE = "retrieval.passage"
+ QUERY = "retrieval.query"
+
+
+def _normalize(vector: list[float]) -> list[float]:
+ """L2-normalize an embedding vector — critical for cosine/dot-product search quality."""
+ arr = np.array(vector, dtype=np.float32)
+ norm = np.linalg.norm(arr)
+ if norm > 0:
+ arr = arr / norm
+ return arr.tolist()
+
+
+def _is_retryable(exc: httpx.HTTPStatusError) -> bool:
+ status = exc.response.status_code
+ return status == 429 or status >= 500
+
+
+def _post_with_retries(payload: dict) -> dict:
+ if not settings.jina_api_key:
+ # An empty key produces a malformed `Authorization: Bearer ` header,
+ # which httpx rejects locally (never reaches the network) — retrying
+ # that a further 3 times with backoff just delays every caller for no
+ # chance of success. Fail immediately instead.
+ raise RuntimeError("JINA_API_KEY is not configured — cannot call Jina embeddings API")
+
+ last_exc: Exception | None = None
+ for attempt in range(_MAX_RETRIES + 1):
+ try:
+ response = httpx.post(
+ _EMBEDDINGS_URL,
+ headers={
+ "Authorization": f"Bearer {settings.jina_api_key}",
+ "Content-Type": "application/json",
+ },
+ json=payload,
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPStatusError as exc:
+ last_exc = exc
+ if not _is_retryable(exc):
+ # 4xx (bad request, bad key, etc.) — retrying won't help.
+ raise
+ except httpx.TransportError as exc:
+ last_exc = exc
+
+ if attempt < _MAX_RETRIES:
+ delay = _RETRY_BACKOFF_SECONDS[attempt]
+ logger.warning(
+ "jina_embeddings: attempt %s/%s failed (%s), retrying in %ss",
+ attempt + 1,
+ _MAX_RETRIES + 1,
+ last_exc,
+ delay,
+ )
+ time.sleep(delay)
+
+ logger.error(
+ "jina_embeddings: all %s attempts failed (%s)", _MAX_RETRIES + 1, last_exc
+ )
+ raise last_exc
+
+
+def _chunk(items: list[str], size: int) -> list[list[str]]:
+ return [items[i : i + size] for i in range(0, len(items), size)]
+
+
+def embed_texts(
+ texts: list[str], task: EmbeddingTask = EmbeddingTask.PASSAGE
+) -> list[list[float]]:
+ """Embed a batch of texts, batching internally if needed. Returns L2-normalized
+ vectors in the same order as the input."""
+ if not texts:
+ return []
+
+ all_embeddings: list[list[float]] = []
+ for batch in _chunk(texts, _MAX_BATCH_SIZE):
+ data = _post_with_retries(
+ {
+ "model": settings.jina_embedding_model,
+ "input": batch,
+ "task": task.value,
+ }
+ )
+ # Jina returns results tagged with their input index; sort defensively
+ # in case the API doesn't guarantee response order matches request order.
+ ordered = sorted(data["data"], key=lambda item: item.get("index", 0))
+ all_embeddings.extend(_normalize(item["embedding"]) for item in ordered)
+
+ return all_embeddings
+
+
+def embed_text(
+ text_input: str, task: EmbeddingTask = EmbeddingTask.PASSAGE
+) -> list[float]:
+ """Embed a single text; convenience wrapper around embed_texts."""
+ return embed_texts([text_input], task=task)[0]
+
+
+def embed_passage(text_input: str) -> list[float]:
+ """Embed a document being indexed (e.g. a venue's search document)."""
+ return embed_text(text_input, task=EmbeddingTask.PASSAGE)
+
+
+def embed_passages(texts: list[str]) -> list[list[float]]:
+ """Embed a batch of documents being indexed."""
+ return embed_texts(texts, task=EmbeddingTask.PASSAGE)
+
+
+def embed_query(query: str) -> list[float]:
+ """Embed a user's search query."""
+ return embed_text(query, task=EmbeddingTask.QUERY)
diff --git a/apps/api/app/infrastructure/llm/__init__.py b/apps/api/app/infrastructure/llm/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/infrastructure/llm/groq.py b/apps/api/app/infrastructure/llm/groq.py
new file mode 100644
index 000000000..c2d653286
--- /dev/null
+++ b/apps/api/app/infrastructure/llm/groq.py
@@ -0,0 +1,83 @@
+"""Groq chat-completions client (OpenAI-compatible API).
+
+This module is the only place that knows about Groq's base URL, auth header,
+and request/response shape. Callers (e.g.
+app.modules.deep_research.query_understanding) should only ever import
+chat_completion from here — they should never need to know this is Groq, or
+what the HTTP contract looks like. Mirrors the retry/backoff shape of
+app.infrastructure.embeddings.jina.
+"""
+
+import logging
+import time
+
+import httpx
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+_MAX_RETRIES = 2
+_RETRY_BACKOFF_SECONDS = [1, 3]
+
+
+def _is_retryable(exc: httpx.HTTPStatusError) -> bool:
+ status = exc.response.status_code
+ return status == 429 or status >= 500
+
+
+def chat_completion(
+ messages: list[dict[str, str]], temperature: float = 0.0
+) -> str:
+ """Send a chat-completions request to Groq, returning the assistant
+ message content. Raises on non-retryable errors (bad key, bad request)
+ and after exhausting retries on transient failures."""
+ if not settings.groq_api_key:
+ # An empty key produces a malformed `Authorization: Bearer ` header,
+ # which httpx rejects locally before ever reaching the network —
+ # retrying that is guaranteed to fail again. Fail fast instead.
+ raise RuntimeError("GROQ_API_KEY is not configured — cannot call Groq")
+
+ payload = {
+ "model": settings.groq_model,
+ "messages": messages,
+ "temperature": temperature,
+ }
+
+ last_exc: Exception | None = None
+ for attempt in range(_MAX_RETRIES + 1):
+ try:
+ response = httpx.post(
+ f"{settings.groq_base_url}/chat/completions",
+ headers={
+ "Authorization": f"Bearer {settings.groq_api_key}",
+ "Content-Type": "application/json",
+ },
+ json=payload,
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data["choices"][0]["message"]["content"]
+ except httpx.HTTPStatusError as exc:
+ last_exc = exc
+ if not _is_retryable(exc):
+ raise
+ except httpx.TransportError as exc:
+ last_exc = exc
+
+ if attempt < _MAX_RETRIES:
+ delay = _RETRY_BACKOFF_SECONDS[attempt]
+ logger.warning(
+ "groq_chat_completion: attempt %s/%s failed (%s), retrying in %ss",
+ attempt + 1,
+ _MAX_RETRIES + 1,
+ last_exc,
+ delay,
+ )
+ time.sleep(delay)
+
+ logger.error(
+ "groq_chat_completion: all %s attempts failed (%s)", _MAX_RETRIES + 1, last_exc
+ )
+ raise last_exc
diff --git a/apps/api/app/jobs/balance_overdue.py b/apps/api/app/jobs/balance_overdue.py
new file mode 100644
index 000000000..77bcc66b2
--- /dev/null
+++ b/apps/api/app/jobs/balance_overdue.py
@@ -0,0 +1,97 @@
+import logging
+from datetime import datetime, timezone, timedelta
+
+from app.core.database import with_session
+from app.modules.booking.models import (
+ Booking, BookingStatus, PaymentStatus, BookingStatusHistory,
+)
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_ACTION_WINDOW_HOURS = 48
+BATCH = 100
+
+
+def run_flag() -> int:
+ """Flag confirmed-but-balance-unpaid bookings whose balance due date has
+ passed, opening the owner-action window (extend / forfeit / goodwill).
+ """
+ now = datetime.now(timezone.utc)
+ today = now.date()
+ flagged = 0
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.confirmed,
+ Booking.payment_status == PaymentStatus.advance_paid,
+ Booking.balance_due_date.isnot(None),
+ Booking.balance_due_date < today,
+ Booking.balance_overdue_at.is_(None),
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ venue = db.get(Venue, b.venue_id)
+ window = venue.owner_action_window_hours if venue else DEFAULT_ACTION_WINDOW_HOURS
+ b.balance_overdue_at = now
+ b.owner_action_deadline = now + timedelta(hours=window)
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, b.user_id, "balance_overdue",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ if venue:
+ notifications.notify(db, venue.owner_id, "balance_overdue",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ flagged += 1
+ logger.info("balance_overdue_flag: flagged %d booking(s)", flagged)
+ return flagged
+
+
+def run_autocancel() -> int:
+ """Auto-cancel bookings whose owner-action window expired without the owner
+ extending the deadline or otherwise acting on the overdue balance.
+
+ The advance is NOT refunded here: the customer missed the balance deadline,
+ so the advance is forfeited (this is a system cancel for non-payment, not an
+ owner cancellation). Owner-initiated cancels still refund per their own paths.
+ """
+ now = datetime.now(timezone.utc)
+ cancelled = 0
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.confirmed,
+ Booking.payment_status == PaymentStatus.advance_paid,
+ Booking.balance_overdue_at.isnot(None),
+ Booking.owner_action_deadline.isnot(None),
+ Booking.owner_action_deadline < now,
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ old = b.status
+ b.status = BookingStatus.balance_overdue_cancelled
+ b.cancelled_at = now
+ if b.slot:
+ b.slot.is_blocking = False
+ db.add(BookingStatusHistory(
+ booking_id=b.id, old_status=old,
+ new_status=BookingStatus.balance_overdue_cancelled,
+ reason="balance_overdue_autocancel_job",
+ ))
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, b.user_id, "booking_canceled",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ cancelled += 1
+ logger.info("balance_overdue_autocancel: cancelled %d booking(s)", cancelled)
+ return cancelled
diff --git a/apps/api/app/jobs/booking_completion.py b/apps/api/app/jobs/booking_completion.py
new file mode 100644
index 000000000..5f24c0532
--- /dev/null
+++ b/apps/api/app/jobs/booking_completion.py
@@ -0,0 +1,52 @@
+import logging
+from datetime import datetime, timezone
+
+from app.core.database import with_session
+from app.modules.booking.models import (
+ Booking, BookingSlot, BookingStatus, PaymentStatus, BookingStatusHistory,
+)
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+BATCH = 100
+
+
+def run() -> int:
+ """Mark confirmed bookings completed once the event date has passed and no
+ payment is pending. (Dispute/cancellation workflow checks go here too.)
+ """
+ now = datetime.now(timezone.utc)
+ completed = 0
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .join(BookingSlot, BookingSlot.booking_id == Booking.id)
+ .filter(
+ Booking.status == BookingStatus.confirmed,
+ Booking.payment_status == PaymentStatus.fully_paid,
+ Booking.deleted_at.is_(None),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_ends_at < now,
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ b.status = BookingStatus.completed
+ b.completed_at = now
+ if b.slot:
+ b.slot.is_blocking = False
+ db.add(BookingStatusHistory(
+ booking_id=b.id, old_status=BookingStatus.confirmed,
+ new_status=BookingStatus.completed, reason="booking_completion_job",
+ ))
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, b.user_id, "booking_completed",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ completed += 1
+ logger.info("booking_completion: completed %d booking(s)", completed)
+ return completed
diff --git a/apps/api/app/jobs/hold_expiry.py b/apps/api/app/jobs/hold_expiry.py
new file mode 100644
index 000000000..77b7e774d
--- /dev/null
+++ b/apps/api/app/jobs/hold_expiry.py
@@ -0,0 +1,45 @@
+import logging
+from datetime import datetime, timezone
+
+from app.core.database import with_session
+from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingStatusHistory
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+BATCH = 100
+
+
+def run() -> int:
+ """Cancel bookings whose 24-hour advance payment window has expired."""
+ now = datetime.now(timezone.utc)
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.owner_accepted,
+ Booking.hold_expires_at.isnot(None),
+ Booking.hold_expires_at < now,
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ b.status = BookingStatus.hold_expired
+ b.payment_status = PaymentStatus.unpaid
+ b.expired_at = now
+ if b.slot:
+ b.slot.is_blocking = False
+ db.add(BookingStatusHistory(
+ booking_id=b.id, old_status=BookingStatus.owner_accepted,
+ new_status=BookingStatus.hold_expired, reason="hold_expiry_job",
+ ))
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, b.user_id, "hold_expired",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ logger.info("hold_expiry: expired %d booking(s)", len(rows))
+ return len(rows)
diff --git a/apps/api/app/jobs/invoice_generator.py b/apps/api/app/jobs/invoice_generator.py
new file mode 100644
index 000000000..b9901ed41
--- /dev/null
+++ b/apps/api/app/jobs/invoice_generator.py
@@ -0,0 +1,39 @@
+import logging
+
+from app.core import job_queue
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.modules.booking.invoice import process, retryable_invoice_ids
+
+logger = logging.getLogger(__name__)
+
+
+def run() -> int:
+ db = SessionLocal()
+
+ try:
+ invoice_ids = job_queue.dequeue_from_redis(settings.upstash_invoice_queue_key, limit=10)
+ source = "Redis"
+
+ if not invoice_ids:
+ invoice_ids = retryable_invoice_ids(db, limit=10)
+ source = "Database"
+
+ if not invoice_ids:
+ return 0
+
+ logger.info("Invoice generator: processing %d job(s) from %s", len(invoice_ids), source)
+
+ processed = 0
+ for invoice_id in invoice_ids:
+ try:
+ process(db, invoice_id)
+ processed += 1
+ except Exception:
+ logger.exception("Failed processing invoice job %s", invoice_id)
+
+ logger.info("Invoice generator: finished (%d/%d processed)", processed, len(invoice_ids))
+ return processed
+
+ finally:
+ db.close()
diff --git a/apps/api/app/jobs/payment_pending_expiry.py b/apps/api/app/jobs/payment_pending_expiry.py
new file mode 100644
index 000000000..d7b2e8d76
--- /dev/null
+++ b/apps/api/app/jobs/payment_pending_expiry.py
@@ -0,0 +1,45 @@
+import logging
+from datetime import datetime, timezone
+
+from app.core.database import with_session
+from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingStatusHistory
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+BATCH = 100
+
+
+def run() -> int:
+ """Cancel bookings whose 15-minute payment pending window has expired."""
+ now = datetime.now(timezone.utc)
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.payment_pending,
+ Booking.payment_expires_at.isnot(None),
+ Booking.payment_expires_at < now,
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ b.status = BookingStatus.hold_expired
+ b.payment_status = PaymentStatus.unpaid
+ b.expired_at = now
+ if b.slot:
+ b.slot.is_blocking = False
+ db.add(BookingStatusHistory(
+ booking_id=b.id, old_status=BookingStatus.payment_pending,
+ new_status=BookingStatus.hold_expired, reason="payment_pending_expiry_job",
+ ))
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, b.user_id, "hold_expired",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ logger.info("payment_pending_expiry: expired %d booking(s)", len(rows))
+ return len(rows)
diff --git a/apps/api/app/jobs/payment_reminders.py b/apps/api/app/jobs/payment_reminders.py
new file mode 100644
index 000000000..69caab2a7
--- /dev/null
+++ b/apps/api/app/jobs/payment_reminders.py
@@ -0,0 +1,58 @@
+import logging
+from datetime import datetime, timezone, timedelta
+
+from app.core.database import with_session
+from app.modules.booking.models import Booking, BookingStatus
+from app.modules.notification.models import InAppNotification
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+# The advance hold is only 24h, so reminders are keyed to the hold expiry, not
+# days-before-event. Remind once when the hold is within this window of expiring.
+REMINDER_BEFORE = timedelta(hours=12)
+BATCH = 100
+
+
+def run() -> int:
+ """Remind accepted-but-unpaid users to pay the token advance before their
+ 24-hour hold expires. Deduped via the in-app notification row so a user gets
+ at most one reminder per booking even if the job runs repeatedly.
+ """
+ now = datetime.now(timezone.utc)
+ sent = 0
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.owner_accepted,
+ Booking.hold_expires_at.isnot(None),
+ Booking.hold_expires_at > now,
+ Booking.hold_expires_at <= now + REMINDER_BEFORE,
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ already = (
+ db.query(InAppNotification)
+ .filter(
+ InAppNotification.booking_id == b.id,
+ InAppNotification.type == "payment_reminder",
+ )
+ .first()
+ )
+ if already:
+ continue
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "your venue"
+ hours_left = max(0, int((b.hold_expires_at - now).total_seconds() // 3600))
+ notifications.notify(db, b.user_id, "payment_reminder",
+ context={"venue_name": venue_name, "hours_left": hours_left},
+ booking_id=b.id)
+ sent += 1
+ logger.info("payment_reminders: sent %d reminder(s)", sent)
+ return sent
diff --git a/apps/api/app/jobs/runner.py b/apps/api/app/jobs/runner.py
new file mode 100644
index 000000000..387116290
--- /dev/null
+++ b/apps/api/app/jobs/runner.py
@@ -0,0 +1,54 @@
+"""Single source of truth for the background-job catalogue.
+
+The same canonical `app/jobs/*` functions are run three ways:
+ - in-process via APScheduler (`app/jobs/scheduler.py`), when ENABLE_JOBS=true
+ - from the CLI (`apps/api/run_job.py`)
+ - from the machine-to-machine endpoint (`POST /api/internal/run-jobs`)
+
+Each job manages its own session (commit on success, rollback on error) and
+returns the number of rows it processed.
+"""
+import logging
+
+from app.jobs import (
+ hold_expiry,
+ stale_requests,
+ booking_completion,
+ payment_reminders,
+ balance_overdue,
+ search_indexer,
+ payment_pending_expiry,
+ invoice_generator,
+)
+
+logger = logging.getLogger(__name__)
+
+# name -> callable. Names are stable; the cron triggers reference them.
+JOBS = {
+ "hold_expiry": hold_expiry.run,
+ "request_expiry": stale_requests.run,
+ "completion": booking_completion.run,
+ "payment_reminders": payment_reminders.run,
+ "overdue_flag": balance_overdue.run_flag,
+ "overdue_autocancel": balance_overdue.run_autocancel,
+ "search_indexer": search_indexer.run,
+ "payment_pending_expiry": payment_pending_expiry.run,
+ "invoice_generator": invoice_generator.run,
+}
+
+
+def run_job(job_name: str) -> int:
+ """Run one named job and return the count it processed.
+
+ Raises KeyError if the name is unknown so callers can map it to a 400.
+ """
+ job_func = JOBS[job_name]
+ logger.info("running job %s", job_name)
+ count = job_func()
+ logger.info("job %s processed %s row(s)", job_name, count)
+ return count
+
+
+def run_all() -> dict[str, int]:
+ """Run every job once; return a {name: count} summary."""
+ return {name: run_job(name) for name in JOBS}
diff --git a/apps/api/app/jobs/scheduler.py b/apps/api/app/jobs/scheduler.py
new file mode 100644
index 000000000..ef2f50a29
--- /dev/null
+++ b/apps/api/app/jobs/scheduler.py
@@ -0,0 +1,32 @@
+from apscheduler.schedulers.background import BackgroundScheduler
+from app.jobs import (
+ hold_expiry,
+ stale_requests,
+ payment_reminders,
+ booking_completion,
+ balance_overdue,
+ search_indexer,
+ payment_pending_expiry,
+ invoice_generator,
+)
+
+scheduler = BackgroundScheduler()
+
+
+def start():
+ scheduler.add_job(payment_pending_expiry.run, "interval", minutes=1, id="payment_pending_expiry")
+ scheduler.add_job(invoice_generator.run, "interval", minutes=1, id="invoice_generator")
+ scheduler.add_job(hold_expiry.run, "interval", hours=1, id="hold_expiry")
+ scheduler.add_job(stale_requests.run, "interval", hours=6, id="stale_requests")
+ # Hourly so the 12h pre-hold-expiry reminder window is reliably caught.
+ scheduler.add_job(payment_reminders.run, "interval", hours=1, id="payment_reminders")
+ scheduler.add_job(booking_completion.run, "cron", hour=0, id="booking_completion")
+ scheduler.add_job(balance_overdue.run_flag, "interval", hours=6, id="balance_overdue_flag")
+ scheduler.add_job(balance_overdue.run_autocancel, "interval", hours=6, id="balance_overdue_autocancel")
+ scheduler.add_job(search_indexer.run, "interval", hours=1, id="search_indexer")
+ scheduler.start()
+
+
+def shutdown():
+ if scheduler.running:
+ scheduler.shutdown(wait=False)
diff --git a/apps/api/app/jobs/search_indexer.py b/apps/api/app/jobs/search_indexer.py
new file mode 100644
index 000000000..179a9863e
--- /dev/null
+++ b/apps/api/app/jobs/search_indexer.py
@@ -0,0 +1,53 @@
+import logging
+
+from app.core import job_queue
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.modules.search.indexer import process_job, retryable_job_ids
+
+logger = logging.getLogger(__name__)
+
+
+def run() -> int:
+ db = SessionLocal()
+
+ try:
+ job_ids = job_queue.dequeue_from_redis(settings.upstash_search_queue_key, limit=10)
+ source = "Redis"
+
+ if not job_ids:
+ job_ids = retryable_job_ids(db, limit=10)
+ source = "Database"
+
+ if not job_ids:
+ logger.info("Search indexer: no jobs")
+ return 0
+
+ logger.info(
+ "Search indexer: processing %d job(s) from %s",
+ len(job_ids),
+ source,
+ )
+
+ processed = 0
+
+ for job_id in job_ids:
+ try:
+ process_job(db, job_id)
+ processed += 1
+ except Exception:
+ logger.exception(
+ "Failed processing search index job %s",
+ job_id,
+ )
+
+ logger.info(
+ "Search indexer: finished (%d/%d processed)",
+ processed,
+ len(job_ids),
+ )
+
+ return processed
+
+ finally:
+ db.close()
diff --git a/apps/api/app/jobs/stale_requests.py b/apps/api/app/jobs/stale_requests.py
new file mode 100644
index 000000000..e09fda7d1
--- /dev/null
+++ b/apps/api/app/jobs/stale_requests.py
@@ -0,0 +1,45 @@
+import logging
+from datetime import datetime, timezone, timedelta
+
+from app.core.database import with_session
+from app.modules.booking.models import Booking, BookingStatus, BookingStatusHistory
+from app.modules.venue.models import Venue
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+STALE_AFTER = timedelta(days=7)
+BATCH = 100
+
+
+def run() -> int:
+ """Auto-expire booking requests that have been pending (requested) for 7 days."""
+ now = datetime.now(timezone.utc)
+ cutoff = now - STALE_AFTER
+ expired = 0
+ with with_session() as db:
+ rows = (
+ db.query(Booking)
+ .filter(
+ Booking.status == BookingStatus.requested,
+ Booking.requested_at < cutoff,
+ Booking.deleted_at.is_(None),
+ )
+ .with_for_update(skip_locked=True)
+ .limit(BATCH)
+ .all()
+ )
+ for b in rows:
+ b.status = BookingStatus.request_expired
+ b.expired_at = now
+ db.add(BookingStatusHistory(
+ booking_id=b.id, old_status=BookingStatus.requested,
+ new_status=BookingStatus.request_expired, reason="stale_requests_job",
+ ))
+ venue = db.get(Venue, b.venue_id)
+ venue_name = venue.name if venue else "the venue"
+ notifications.notify(db, b.user_id, "request_expired",
+ context={"venue_name": venue_name}, booking_id=b.id)
+ expired += 1
+ logger.info("stale_requests: expired %d request(s)", expired)
+ return expired
diff --git a/apps/api/app/main.py b/apps/api/app/main.py
new file mode 100644
index 000000000..ce965e5d1
--- /dev/null
+++ b/apps/api/app/main.py
@@ -0,0 +1,82 @@
+import logging
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.core.logging import setup_logging
+from app.core.middleware import register_middleware
+from app.jobs import scheduler as job_scheduler
+from app.modules.auth.routes import router as auth_router
+from app.modules.profile.routes import router as profile_router
+from app.modules.venue.routes import router as venue_router
+from app.modules.search.routes import router as search_router
+from app.modules.search import search_metadata_cache, query_normalizer
+from app.modules.booking.routes import router as booking_router
+from app.modules.availability.routes import router as availability_router
+from app.modules.notification.routes import router as notification_router
+from app.modules.admin.routes import router as admin_router
+from app.modules.payment.routes import router as payment_router
+from app.modules.internal.routes import router as internal_router
+from app.modules.owner.routes import router as owner_router
+from app.modules.deep_research.routes import router as deep_research_router
+from app.modules.review.routes import router as review_router
+from app.modules.admin.service import seed_super_admin
+
+logger = logging.getLogger(__name__)
+
+
+def _load_search_metadata() -> None:
+ """Populate the search metadata cache (category boost groups, keyword
+ expansion, query-normalizer vocabulary) from the DB. Runs at startup so
+ none of it is hardcoded in the search module — see
+ app/modules/search/search_metadata_cache.py.
+ """
+ with SessionLocal() as db:
+ search_metadata_cache.load_category_metadata(db)
+ vocabulary = search_metadata_cache.build_query_vocabulary(db)
+ query_normalizer.set_dynamic_vocabulary(vocabulary)
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+ setup_logging()
+ seed_super_admin()
+ _load_search_metadata()
+ if settings.enable_jobs:
+ logger.info("ENABLE_JOBS=true — starting background scheduler")
+ job_scheduler.start()
+ else:
+ logger.info("ENABLE_JOBS=false — background scheduler not started")
+ yield
+ if settings.enable_jobs:
+ job_scheduler.shutdown()
+
+
+app = FastAPI(title="Venue404 API", lifespan=lifespan)
+
+register_middleware(app)
+
+app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
+app.include_router(profile_router, prefix="/api/profile", tags=["profile"])
+app.include_router(venue_router, prefix="/api/venues", tags=["venues"])
+app.include_router(review_router, prefix="/api", tags=["reviews"])
+app.include_router(search_router, prefix="/api/search", tags=["search"])
+app.include_router(booking_router, prefix="/api/bookings", tags=["bookings"])
+app.include_router(
+ availability_router, prefix="/api/availability", tags=["availability"]
+)
+app.include_router(
+ notification_router, prefix="/api/notifications", tags=["notifications"]
+)
+app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
+app.include_router(payment_router, prefix="/api/payments", tags=["payments"])
+app.include_router(internal_router, prefix="/api/internal", tags=["internal"])
+app.include_router(owner_router, prefix="/api/owner", tags=["owner"])
+app.include_router(
+ deep_research_router, prefix="/api/deep-research", tags=["deep-research"]
+)
+
+
+@app.get("/health", tags=["health"])
+async def health_check():
+ return {"status": "ok"}
diff --git a/apps/api/app/modules/admin/models.py b/apps/api/app/modules/admin/models.py
new file mode 100644
index 000000000..746427999
--- /dev/null
+++ b/apps/api/app/modules/admin/models.py
@@ -0,0 +1,19 @@
+import uuid
+from datetime import datetime
+from sqlalchemy import String, DateTime, Text, ForeignKey, JSON, func
+from sqlalchemy.orm import mapped_column, Mapped
+from sqlalchemy.dialects.postgresql import UUID
+from app.core.database import Base
+
+
+class AdminAction(Base):
+ __tablename__ = "admin_actions"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ admin_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False)
+ action_type: Mapped[str] = mapped_column(String, nullable=False)
+ target_type: Mapped[str] = mapped_column(String, nullable=False)
+ target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
+ reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+ action_metadata: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
diff --git a/apps/api/app/modules/admin/routes.py b/apps/api/app/modules/admin/routes.py
new file mode 100644
index 000000000..164eeccc2
--- /dev/null
+++ b/apps/api/app/modules/admin/routes.py
@@ -0,0 +1,426 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.admin.schemas import (
+ VenueActionRequest,
+ AdminVenueListResponse,
+ UserListResponse,
+ UserSummary,
+ SuspendUserRequest,
+ ReactivateUserRequest,
+ AdminActionListResponse,
+ OwnerApprovalRequest,
+ OwnerStatsResponse,
+ AmenityCreateRequest,
+ AmenityUpdateRequest,
+ AdminAmenityResponse,
+ AmenityListResponse,
+ AmenityDeleteResponse,
+ CategoryCreateRequest,
+ CategoryUpdateRequest,
+ AdminCategoryResponse,
+ CategoryListResponse,
+ CategoryDeleteResponse,
+ CategoryBannerResponse,
+ BookingStatsResponse,
+ AdminBookingListResponse,
+ VenueStatsResponse,
+ GrowthStatsResponse,
+ DeepResearchQueryDetail,
+ DeepResearchQueryListResponse,
+ DeepResearchStatsResponse,
+ ExternalReservationListResponse,
+ ContactOwnerRequest,
+ MarkInterestedRequest,
+ InviteOwnerRequest,
+ InviteOwnerResponse,
+)
+from app.modules.auth.dependencies import require_admin, AuthContext
+from app.modules.admin import service
+from app.modules.deep_research import service as reservation_service
+
+router = APIRouter()
+
+
+@router.get("/venues/stats", response_model=VenueStatsResponse)
+def get_venue_stats(
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_venue_stats(db)
+
+
+@router.get("/venues", response_model=AdminVenueListResponse)
+def list_venues(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ status: str | None = Query(None, pattern="^(draft|pending_approval|approved|rejected|suspended)$"),
+ search: str | None = Query(None),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_admin_venues(db, status=status, search=search, page=page, page_size=page_size)
+
+
+@router.patch("/venues/{venue_id}/approve", status_code=204)
+def approve_venue(
+ venue_id: UUID,
+ body: VenueActionRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.approve_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason)
+
+
+@router.patch("/venues/{venue_id}/reject", status_code=204)
+def reject_venue(
+ venue_id: UUID,
+ body: VenueActionRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.reject_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason)
+
+
+@router.patch("/venues/{venue_id}/suspend", status_code=204)
+def suspend_venue(
+ venue_id: UUID,
+ body: VenueActionRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.suspend_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason)
+
+
+@router.patch("/venues/{venue_id}/reactivate", status_code=204)
+def reactivate_venue(
+ venue_id: UUID,
+ body: VenueActionRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.reactivate_venue(db, admin_id=auth.user_id, venue_id=venue_id, reason=body.reason)
+
+
+@router.get("/users", response_model=UserListResponse)
+def list_users(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ search: str | None = Query(None),
+ status: str | None = Query(None, pattern="^(active|suspended|pending|rejected)$"),
+ role: str | None = Query(None, pattern="^(customer|venue_owner|super_admin)$"),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_users(
+ db,
+ page=page,
+ page_size=page_size,
+ search=search,
+ status=status,
+ role=role,
+ )
+
+
+@router.get("/users/{user_id}", response_model=UserSummary)
+def get_user(
+ user_id: UUID,
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_user(db, user_id)
+
+
+@router.patch("/users/{user_id}/suspend", status_code=204)
+def suspend_user(
+ user_id: UUID,
+ body: SuspendUserRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.suspend_user(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason)
+
+
+@router.patch("/users/{user_id}/reactivate", status_code=204)
+def reactivate_user(
+ user_id: UUID,
+ body: ReactivateUserRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.reactivate_user(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason)
+
+
+@router.get("/venue-owners/stats", response_model=OwnerStatsResponse)
+def get_owner_stats(
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_owner_stats(db)
+
+
+@router.patch("/venue-owners/{user_id}/approve", status_code=204)
+def approve_owner(
+ user_id: UUID,
+ body: OwnerApprovalRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.approve_owner(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason)
+
+
+@router.patch("/venue-owners/{user_id}/reject", status_code=204)
+def reject_owner(
+ user_id: UUID,
+ body: OwnerApprovalRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ service.reject_owner(db, admin_id=auth.user_id, user_id=user_id, reason=body.reason)
+
+
+@router.get("/actions", response_model=AdminActionListResponse)
+def list_actions(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ target_type: str | None = Query(None, pattern="^(user|venue|booking|amenity|external_reservation)$"),
+ action_type: str | None = Query(None),
+ # Legacy convenience: limit=N returns N items on page 1 (used by dashboard)
+ limit: int | None = Query(None, ge=1, le=100),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_actions(
+ db, page=page, page_size=page_size,
+ target_type=target_type, action_type=action_type, limit=limit,
+ )
+
+
+@router.get("/amenities", response_model=AmenityListResponse)
+def list_amenities(
+ include_deleted: bool = Query(False),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_amenities(db, include_deleted=include_deleted)
+
+
+@router.post("/amenities", response_model=AdminAmenityResponse, status_code=201)
+def create_amenity(
+ body: AmenityCreateRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.create_amenity(db, admin_id=auth.user_id, name=body.name, icon=body.icon)
+
+
+@router.patch("/amenities/{amenity_id}", response_model=AdminAmenityResponse)
+def update_amenity(
+ amenity_id: UUID,
+ body: AmenityUpdateRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.update_amenity(db, admin_id=auth.user_id, amenity_id=amenity_id, body=body)
+
+
+@router.delete("/amenities/{amenity_id}", response_model=AmenityDeleteResponse)
+def delete_amenity(
+ amenity_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.delete_amenity(db, admin_id=auth.user_id, amenity_id=amenity_id)
+
+
+# ── Category routes ────────────────────────────────────────────────────────────
+
+@router.get("/categories", response_model=CategoryListResponse)
+def list_categories(
+ include_deleted: bool = Query(False),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_categories(db, include_deleted=include_deleted)
+
+
+@router.post("/categories", response_model=AdminCategoryResponse, status_code=201)
+def create_category(
+ body: CategoryCreateRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.create_category(
+ db,
+ admin_id=auth.user_id,
+ slug=body.slug,
+ label=body.label,
+ icon=body.icon,
+ sort_order=body.sort_order,
+ )
+
+
+@router.patch("/categories/{category_id}", response_model=AdminCategoryResponse)
+def update_category(
+ category_id: UUID,
+ body: CategoryUpdateRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.update_category(db, admin_id=auth.user_id, category_id=category_id, body=body)
+
+
+@router.post("/categories/{category_id}/banner-image", response_model=CategoryBannerResponse)
+async def upload_category_banner(
+ category_id: UUID,
+ file: UploadFile = File(...),
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ if not file.content_type or not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="File must be an image")
+ file_bytes = await file.read()
+ return service.upload_category_banner(db, admin_id=auth.user_id, category_id=category_id, file_bytes=file_bytes)
+
+
+@router.delete("/categories/{category_id}/banner-image", response_model=CategoryBannerResponse)
+def delete_category_banner(
+ category_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.delete_category_banner(db, admin_id=auth.user_id, category_id=category_id)
+
+
+@router.delete("/categories/{category_id}", response_model=CategoryDeleteResponse)
+def delete_category(
+ category_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.delete_category(db, admin_id=auth.user_id, category_id=category_id)
+
+
+# ─── Booking routes ────────────────────────────────────────────────────────────
+
+@router.get("/bookings/stats", response_model=BookingStatsResponse)
+def get_booking_stats(
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_booking_stats(db)
+
+
+@router.get("/bookings", response_model=AdminBookingListResponse)
+def list_bookings(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(25, ge=1, le=100),
+ status: str | None = Query(None),
+ search: str | None = Query(None),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_admin_bookings(db, status=status, search=search, page=page, page_size=page_size)
+
+
+@router.get("/growth-stats", response_model=GrowthStatsResponse)
+def get_growth_stats(
+ period: str = Query("6m", pattern="^(7d|30d|3m|6m|12m)$"),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_growth_stats(db, period=period)
+
+
+# ─── Deep Research observability ───────────────────────────────────────────────
+
+@router.get("/deep-research/stats", response_model=DeepResearchStatsResponse)
+def get_deep_research_stats(
+ days: int = Query(30, ge=1, le=90),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_deep_research_stats(db, days=days)
+
+
+@router.get("/deep-research/queries", response_model=DeepResearchQueryListResponse)
+def list_deep_research_queries(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ search: str | None = Query(None),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.list_deep_research_queries(db, page=page, page_size=page_size, search=search)
+
+
+@router.get("/deep-research/queries/{query_id}", response_model=DeepResearchQueryDetail)
+def get_deep_research_query(
+ query_id: UUID,
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return service.get_deep_research_query(db, query_id)
+
+
+# ─── External reservation admin workflow ───────────────────────────────────────
+# See docs/Venue404_External_Reservation_Onboarding_PRD.md
+
+@router.get("/external-reservations", response_model=ExternalReservationListResponse)
+def list_external_reservations(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ status: str | None = Query(None),
+ _: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ return reservation_service.list_external_reservations(db, status=status, page=page, page_size=page_size)
+
+
+@router.patch("/external-reservations/{reservation_id}/contact", status_code=204)
+def contact_external_reservation(
+ reservation_id: UUID,
+ body: ContactOwnerRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ reservation_service.contact_reservation(
+ db, admin_id=auth.user_id, reservation_id=reservation_id,
+ contact_method=body.contact_method, notes=body.notes, follow_up_date=body.follow_up_date,
+ )
+
+
+@router.patch("/external-reservations/{reservation_id}/mark-interested", status_code=204)
+def mark_external_reservation_interested(
+ reservation_id: UUID,
+ body: MarkInterestedRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ reservation_service.mark_owner_interested(db, admin_id=auth.user_id, reservation_id=reservation_id, reason=body.reason)
+
+
+@router.post("/external-reservations/{reservation_id}/invite-owner", response_model=InviteOwnerResponse)
+def invite_owner_for_external_reservation(
+ reservation_id: UUID,
+ body: InviteOwnerRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ _reservation, action_link = reservation_service.invite_owner_for_reservation(
+ db, admin_id=auth.user_id, reservation_id=reservation_id,
+ venue_name=body.venue_name, owner_name=body.owner_name, email=body.email, phone=body.phone,
+ category_id=body.category_id,
+ )
+ return InviteOwnerResponse(action_link=action_link)
+
+
+@router.post("/external-reservations/{reservation_id}/create-booking", status_code=204)
+def create_booking_for_external_reservation(
+ reservation_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ reservation_service.create_booking_for_reservation(db, admin_id=auth.user_id, reservation_id=reservation_id)
diff --git a/apps/api/app/modules/admin/schemas.py b/apps/api/app/modules/admin/schemas.py
new file mode 100644
index 000000000..2503565b8
--- /dev/null
+++ b/apps/api/app/modules/admin/schemas.py
@@ -0,0 +1,394 @@
+import uuid
+from datetime import date, datetime
+from pydantic import BaseModel, Field
+from typing import Literal, Optional
+
+
+class VenueApprovalRequest(BaseModel):
+ action: Literal["approve", "reject"]
+ reason: str = ""
+
+
+class UserSummary(BaseModel):
+ id: uuid.UUID
+ full_name: str | None
+ email: str | None
+ phone: str | None
+ status: str
+ roles: list[str]
+ created_at: datetime
+ is_super_admin: bool
+
+ model_config = {"from_attributes": True}
+
+
+class UserStats(BaseModel):
+ total: int
+ active: int
+ suspended: int
+ pending: int
+ rejected: int
+
+
+class UserListResponse(BaseModel):
+ items: list[UserSummary]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
+ stats: UserStats
+
+
+class SuspendUserRequest(BaseModel):
+ reason: str
+
+
+class ReactivateUserRequest(BaseModel):
+ reason: str = ""
+
+
+class AdminActionResponse(BaseModel):
+ id: uuid.UUID
+ admin_id: uuid.UUID
+ admin_name: str | None
+ action_type: str
+ target_type: str
+ target_id: uuid.UUID
+ target_name: str | None
+ reason: str | None
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class AdminActionListResponse(BaseModel):
+ items: list[AdminActionResponse]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
+
+
+class OwnerApprovalRequest(BaseModel):
+ reason: str = ""
+
+
+class OwnerStatsResponse(BaseModel):
+ total: int
+ pending: int
+ active: int
+ rejected: int
+ suspended: int
+
+
+class AmenityCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100)
+ icon: Optional[str] = None
+
+
+class AmenityUpdateRequest(BaseModel):
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
+ icon: Optional[str] = None
+
+
+class AdminAmenityResponse(BaseModel):
+ id: uuid.UUID
+ name: str
+ icon: Optional[str]
+ created_at: datetime
+ deleted_at: Optional[datetime]
+ active_venue_count: int
+
+ model_config = {"from_attributes": True}
+
+
+class AmenityListResponse(BaseModel):
+ items: list[AdminAmenityResponse]
+ total: int
+
+
+class AmenityDeleteResponse(BaseModel):
+ deleted: bool
+ active_venue_count: int
+
+
+# ─── Venue category admin schemas ─────────────────────────────────────────────
+
+class CategoryCreateRequest(BaseModel):
+ slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9_]+$')
+ label: str = Field(..., min_length=1, max_length=100)
+ icon: Optional[str] = None
+ sort_order: int = Field(default=0, ge=0)
+
+
+class CategoryUpdateRequest(BaseModel):
+ label: Optional[str] = Field(None, min_length=1, max_length=100)
+ icon: Optional[str] = None
+ sort_order: Optional[int] = Field(None, ge=0)
+ is_active: Optional[bool] = None
+
+
+class AdminCategoryResponse(BaseModel):
+ id: uuid.UUID
+ slug: str
+ label: str
+ icon: Optional[str]
+ banner_image: Optional[str]
+ is_active: bool
+ sort_order: int
+ created_at: datetime
+ deleted_at: Optional[datetime]
+ venue_count: int
+
+ model_config = {"from_attributes": True}
+
+
+class CategoryListResponse(BaseModel):
+ items: list[AdminCategoryResponse]
+ total: int
+
+
+class CategoryDeleteResponse(BaseModel):
+ deleted: bool
+ venue_count: int
+
+
+class CategoryBannerResponse(BaseModel):
+ banner_image: str
+
+
+# ─── Booking admin schemas ─────────────────────────────────────────────────────
+
+class BookingStatsResponse(BaseModel):
+ total: int
+ requested: int
+ confirmed: int
+ completed: int
+ cancelled: int
+
+
+class AdminBookingSummary(BaseModel):
+ id: uuid.UUID
+ venue_id: uuid.UUID
+ venue_name: str
+ customer_name: str | None
+ customer_email: str | None
+ customer_phone: str | None
+ owner_id: uuid.UUID
+ owner_name: str | None
+ owner_email: str | None
+ owner_phone: str | None
+ status: str
+ payment_status: str
+ event_date: str
+ guest_count: int
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class AdminBookingListResponse(BaseModel):
+ items: list[AdminBookingSummary]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
+ stats: BookingStatsResponse
+
+
+# ─── Venue admin schemas ───────────────────────────────────────────────────────
+
+class GrowthStatsResponse(BaseModel):
+ labels: list[str]
+ users: list[int]
+ owners: list[int]
+ venues: list[int]
+ bookings: list[int]
+ totals: dict[str, int]
+
+
+class VenueStatsResponse(BaseModel):
+ total: int
+ pending_approval: int
+ approved: int
+ rejected: int
+ suspended: int
+ draft: int
+
+
+class VenueActionRequest(BaseModel):
+ reason: str = ""
+
+
+class AdminVenueOwner(BaseModel):
+ id: uuid.UUID
+ full_name: str | None
+ email: str | None
+
+
+class AdminVenueItem(BaseModel):
+ id: uuid.UUID
+ name: str
+ slug: str | None
+ description: str | None
+ category_slug: str
+ address_line1: str
+ city: str
+ state: str
+ country: str
+ min_capacity: int | None
+ max_capacity: int
+ open_time: str
+ close_time: str
+ pricing_mode: str
+ starting_price_paise: int | None
+ hourly_rate_paise: int | None
+ advance_pct: float
+ platform_commission_pct: float
+ status: str
+ is_active: bool
+ cover_photo_url: str | None
+ amenities: list[str]
+ owner: AdminVenueOwner
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class AdminVenueStats(BaseModel):
+ total: int
+ pending_approval: int
+ approved: int
+ rejected: int
+ suspended: int
+ draft: int
+
+
+class AdminVenueListResponse(BaseModel):
+ items: list[AdminVenueItem]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
+ stats: AdminVenueStats
+
+
+# ── Deep Research observability ────────────────────────────────────────────────
+# Read-only reporting over app.modules.deep_research.models.DeepResearchQuery.
+# No admin_actions audit entries needed here (nothing mutates) — contrast with
+# the Phase 3 lead/reservation admin endpoints, which will need audit logging.
+
+
+class DeepResearchTopResult(BaseModel):
+ id: str
+ name: str
+ match_source: Optional[str] = None
+ match_score: Optional[float] = None
+
+
+class DeepResearchQuerySummary(BaseModel):
+ model_config = {"from_attributes": True}
+
+ id: uuid.UUID
+ user_id: uuid.UUID
+ query_text: str
+ city_filter: Optional[str] = None
+ result_count: int
+ avg_match_score: Optional[float] = None
+ created_at: datetime
+
+
+class DeepResearchQueryDetail(DeepResearchQuerySummary):
+ understanding_json: Optional[dict] = None
+ top_results_json: Optional[list[DeepResearchTopResult]] = None
+
+
+class DeepResearchQueryListResponse(BaseModel):
+ items: list[DeepResearchQuerySummary]
+ total: int
+ page: int
+ page_size: int
+
+
+class DeepResearchStatsResponse(BaseModel):
+ labels: list[str]
+ query_counts: list[int]
+ avg_match_scores: list[Optional[float]]
+ total_queries: int
+ avg_result_count: float
+ avg_match_score_overall: Optional[float] = None
+
+
+# ─── External reservation admin workflow ──────────────────────────────────────
+# Converts a customer's external-venue reservation into an onboarded owner +
+# venue + normal Venue404 booking. See docs/Venue404_External_Reservation_Onboarding_PRD.md
+
+class ExternalReservationSummary(BaseModel):
+ id: uuid.UUID
+ status: str
+ lead_name: str
+ lead_city: Optional[str] = None
+ lead_formatted_address: Optional[str] = None
+ lead_category_guess: Optional[str] = None
+ lead_cover_photo_url: Optional[str] = None
+ lead_phone: Optional[str] = None
+ lead_website: Optional[str] = None
+ lead_rating: Optional[float] = None
+ lead_google_maps_uri: Optional[str] = None
+ customer_name: Optional[str] = None
+ customer_email: Optional[str] = None
+ customer_phone: Optional[str] = None
+ customer_notes: Optional[str] = None
+ category_id: Optional[uuid.UUID] = None
+ category_label: Optional[str] = None
+ guest_count: Optional[int] = None
+ event_date: Optional[str] = None
+ owner_id: Optional[uuid.UUID] = None
+ venue_id: Optional[uuid.UUID] = None
+ booking_id: Optional[uuid.UUID] = None
+ contact_method: Optional[str] = None
+ contact_notes: Optional[str] = None
+ follow_up_date: Optional[str] = None
+ owner_invited_at: Optional[datetime] = None
+ booking_created_at: Optional[datetime] = None
+ created_at: datetime
+
+
+class ExternalReservationListResponse(BaseModel):
+ items: list[ExternalReservationSummary]
+ total: int
+ page: int
+ page_size: int
+ total_pages: int
+
+
+class ContactOwnerRequest(BaseModel):
+ contact_method: str = Field(..., min_length=1, max_length=100)
+ notes: str = ""
+ follow_up_date: Optional[date] = None
+
+
+class MarkInterestedRequest(BaseModel):
+ reason: str = ""
+
+
+class InviteOwnerRequest(BaseModel):
+ venue_name: str = Field(..., min_length=1, max_length=200)
+ owner_name: Optional[str] = None
+ email: str
+ phone: Optional[str] = None
+ # Fallback only — normally the reservation already has one, picked by the
+ # customer at reservation time. Lets an admin fill it in for older
+ # reservations created before that field existed.
+ category_id: Optional[uuid.UUID] = None
+
+
+class InviteOwnerResponse(BaseModel):
+ # Always populated so the admin can share it manually (WhatsApp, etc.) if
+ # email delivery to the owner failed or isn't configured yet.
+ action_link: str
+
+
+class ReservationActionRequest(BaseModel):
+ reason: str = ""
diff --git a/apps/api/app/modules/admin/service.py b/apps/api/app/modules/admin/service.py
new file mode 100644
index 000000000..893398d35
--- /dev/null
+++ b/apps/api/app/modules/admin/service.py
@@ -0,0 +1,1372 @@
+import json
+import logging
+import math
+import urllib.request
+import uuid
+from datetime import datetime, timedelta, timezone
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import func, case, text, cast
+from sqlalchemy.dialects.postgresql import UUID as PGUUID
+from sqlalchemy.exc import IntegrityError
+
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError
+from app.modules.admin.models import AdminAction
+from app.modules.admin.schemas import (
+ AmenityUpdateRequest,
+ CategoryUpdateRequest,
+ DeepResearchQueryDetail,
+ DeepResearchQueryListResponse,
+ DeepResearchQuerySummary,
+ DeepResearchStatsResponse,
+)
+from app.modules.profile.models import Profile, UserRoleAssignment, UserRole, ProfileStatus
+from app.modules.venue.models import Amenity, VenueAmenity, Venue, VenueCategory, VenueStatus
+from app.modules.booking.models import Booking, BookingSlot, BookingStatus
+from app.modules.deep_research.models import DeepResearchQuery
+from app.core.storage import upload_image_to_cloudinary, delete_image_from_cloudinary
+
+
+
+logger = logging.getLogger(__name__)
+
+def _month_start(year: int, month: int) -> datetime:
+ return datetime(year, month, 1, tzinfo=timezone.utc)
+
+
+def _add_months(dt: datetime, n: int) -> datetime:
+ total = dt.year * 12 + (dt.month - 1) + n
+ y, m = divmod(total, 12)
+ return _month_start(y, m + 1)
+
+
+def get_growth_stats(db: Session, period: str = "6m") -> dict:
+ from datetime import timedelta
+
+ now = datetime.now(timezone.utc)
+
+ # Parse period → build (start, end, label) buckets + trunc unit
+ VALID = {"7d", "30d", "3m", "6m", "12m"}
+ if period not in VALID:
+ period = "6m"
+
+ use_days = period.endswith("d")
+ buckets: list[tuple[datetime, datetime, str]] = []
+
+ if use_days:
+ n_days = int(period[:-1])
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ for i in range(n_days - 1, -1, -1):
+ start = today_start - timedelta(days=i)
+ end = start + timedelta(days=1)
+ label = f"{start.day} {start.strftime('%b')}"
+ buckets.append((start, end, label))
+ trunc = "day"
+ else:
+ n_months = int(period[:-1])
+ for i in range(n_months - 1, -1, -1):
+ start = _month_start(now.year, now.month)
+ start = _add_months(start, -i)
+ end = _add_months(start, 1)
+ label = start.strftime("%b %y")
+ buckets.append((start, end, label))
+ trunc = "month"
+
+ period_start = buckets[0][0]
+
+ def baseline(model, extra_filter=None):
+ q = db.query(func.count(model.id)).filter(model.created_at < period_start)
+ if extra_filter is not None:
+ q = q.filter(extra_filter)
+ return q.scalar() or 0
+
+ def bucket_new(model, extra_filter=None):
+ q = (
+ db.query(
+ func.date_trunc(trunc, model.created_at).label("bucket"),
+ func.count(model.id).label("cnt"),
+ )
+ .filter(model.created_at >= period_start)
+ )
+ if extra_filter is not None:
+ q = q.filter(extra_filter)
+ rows = q.group_by("bucket").all()
+ return {r.bucket.replace(tzinfo=timezone.utc): r.cnt for r in rows}
+
+ base_users = baseline(Profile)
+ base_owners = baseline(UserRoleAssignment, UserRoleAssignment.role == UserRole.venue_owner)
+ base_venues = baseline(Venue, Venue.deleted_at.is_(None))
+ base_bookings = baseline(Booking, Booking.deleted_at.is_(None))
+
+ new_users = bucket_new(Profile)
+ new_owners = bucket_new(UserRoleAssignment, UserRoleAssignment.role == UserRole.venue_owner)
+ new_venues = bucket_new(Venue, Venue.deleted_at.is_(None))
+ new_bookings = bucket_new(Booking, Booking.deleted_at.is_(None))
+
+ labels, users_s, owners_s, venues_s, bookings_s = [], [], [], [], []
+ cum_u = base_users; cum_o = base_owners; cum_v = base_venues; cum_b = base_bookings
+
+ for start, _end, label in buckets:
+ cum_u += new_users.get(start, 0)
+ cum_o += new_owners.get(start, 0)
+ cum_v += new_venues.get(start, 0)
+ cum_b += new_bookings.get(start, 0)
+ labels.append(label)
+ users_s.append(cum_u)
+ owners_s.append(cum_o)
+ venues_s.append(cum_v)
+ bookings_s.append(cum_b)
+
+ return {
+ "labels": labels,
+ "users": users_s,
+ "owners": owners_s,
+ "venues": venues_s,
+ "bookings": bookings_s,
+ "totals": {
+ "users": users_s[-1] if users_s else 0,
+ "owners": owners_s[-1] if owners_s else 0,
+ "venues": venues_s[-1] if venues_s else 0,
+ "bookings": bookings_s[-1] if bookings_s else 0,
+ },
+ }
+
+
+def get_venue_stats(db: Session) -> dict:
+ row = db.query(Venue).filter(Venue.deleted_at.is_(None)).with_entities(
+ func.count(Venue.id).label("total"),
+ func.count(case((Venue.status == VenueStatus.pending_approval, 1))).label("pending_approval"),
+ func.count(case((Venue.status == VenueStatus.approved, 1))).label("approved"),
+ func.count(case((Venue.status == VenueStatus.rejected, 1))).label("rejected"),
+ func.count(case((Venue.status == VenueStatus.suspended, 1))).label("suspended"),
+ func.count(case((Venue.status == VenueStatus.draft, 1))).label("draft"),
+ ).one()
+ return {
+ "total": row.total,
+ "pending_approval": row.pending_approval,
+ "approved": row.approved,
+ "rejected": row.rejected,
+ "suspended": row.suspended,
+ "draft": row.draft,
+ }
+
+
+def list_admin_venues(
+ db: Session,
+ *,
+ status: str | None = None,
+ search: str | None = None,
+ page: int = 1,
+ page_size: int = 20,
+) -> dict:
+ base = db.query(Venue).filter(Venue.deleted_at.is_(None))
+
+ stats_row = base.with_entities(
+ func.count(Venue.id).label("total"),
+ func.count(case((Venue.status == VenueStatus.pending_approval, 1))).label("pending_approval"),
+ func.count(case((Venue.status == VenueStatus.approved, 1))).label("approved"),
+ func.count(case((Venue.status == VenueStatus.rejected, 1))).label("rejected"),
+ func.count(case((Venue.status == VenueStatus.suspended, 1))).label("suspended"),
+ func.count(case((Venue.status == VenueStatus.draft, 1))).label("draft"),
+ ).one()
+
+ filtered = base
+ if status:
+ filtered = filtered.filter(Venue.status == VenueStatus(status))
+ if search:
+ safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ filtered = filtered.filter(Venue.name.ilike(f"%{safe}%"))
+
+ total = filtered.with_entities(func.count(Venue.id)).scalar()
+ venues = (
+ filtered
+ .options(joinedload(Venue.photos), joinedload(Venue.amenities), joinedload(Venue.category))
+ .order_by(Venue.updated_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ .all()
+ )
+
+ owner_ids = [v.owner_id for v in venues]
+ profiles = db.query(Profile).filter(Profile.id.in_(owner_ids)).all()
+ profile_by_id = {p.id: p for p in profiles}
+
+ items = []
+ for v in venues:
+ cover = next((p.image_url for p in v.photos if p.is_cover and p.deleted_at is None), None)
+ owner = profile_by_id.get(v.owner_id)
+ items.append({
+ "id": v.id,
+ "name": v.name,
+ "slug": v.slug,
+ "description": v.description,
+ "category_slug": v.category.slug,
+ "address_line1": v.address_line1,
+ "city": v.city,
+ "state": v.state,
+ "country": v.country,
+ "min_capacity": v.min_capacity,
+ "max_capacity": v.max_capacity,
+ "open_time": str(v.open_time),
+ "close_time": str(v.close_time),
+ "pricing_mode": v.pricing_mode,
+ "starting_price_paise": v.starting_price_paise,
+ "hourly_rate_paise": v.hourly_rate_paise,
+ "advance_pct": float(v.advance_pct),
+ "platform_commission_pct": float(v.platform_commission_pct),
+ "status": v.status.value,
+ "is_active": v.is_active,
+ "cover_photo_url": cover,
+ "amenities": [a.name for a in v.amenities],
+ "owner": {
+ "id": owner.id if owner else v.owner_id,
+ "full_name": owner.full_name if owner else None,
+ "email": owner.email if owner else None,
+ },
+ "created_at": v.created_at,
+ "updated_at": v.updated_at,
+ })
+
+ return {
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": math.ceil(total / page_size) if total else 1,
+ "stats": {
+ "total": stats_row.total,
+ "pending_approval": stats_row.pending_approval,
+ "approved": stats_row.approved,
+ "rejected": stats_row.rejected,
+ "suspended": stats_row.suspended,
+ "draft": stats_row.draft,
+ },
+ }
+
+
+def _get_venue_or_404(db: Session, venue_id: uuid.UUID) -> Venue:
+ venue = db.query(Venue).filter(Venue.id == venue_id, Venue.deleted_at.is_(None)).first()
+ if not venue:
+ raise NotFoundError("Venue not found")
+ return venue
+
+
+def _check_no_active_bookings(db: Session, venue_id: uuid.UUID) -> None:
+ # to_regclass returns NULL (not an error) when the table doesn't exist, so this query
+ # never fails and never poisons the current transaction.
+ table_exists = db.execute(text("SELECT to_regclass('public.bookings') IS NOT NULL")).scalar()
+ if not table_exists:
+ return # Bookings not yet migrated — skip check
+ try:
+ from app.modules.booking.models import Booking, BookingStatus # noqa: PLC0415
+ count = (
+ db.query(func.count(Booking.id))
+ .filter(
+ cast(Booking.venue_id, PGUUID) == venue_id,
+ Booking.status.in_([
+ BookingStatus.requested, BookingStatus.accepted, BookingStatus.confirmed,
+ ]),
+ )
+ .scalar()
+ )
+ if count and count > 0:
+ raise ConflictError(f"Cannot suspend: venue has {count} active booking(s)")
+ except ConflictError:
+ raise
+ except Exception as e:
+ logger.warning("Booking check error for venue %s: %s", venue_id, e)
+
+
+def approve_venue(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ venue_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ if venue.status != VenueStatus.pending_approval:
+ raise ConflictError("Venue is not pending approval")
+ venue.status = VenueStatus.approved
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="venue_approved",
+ target_type="venue", target_id=venue_id, reason=reason or None,
+ ))
+
+ from app.modules.deep_research.service import sync_reservation_status_for_venue
+ sync_reservation_status_for_venue(db, venue_id, VenueStatus.approved)
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(
+ db, venue.owner_id, NotificationType.VENUE_APPROVED,
+ context={"venue_name": venue.name},
+ )
+
+ db.commit()
+
+ from app.modules.search.indexer import enqueue_job
+ enqueue_job(db, venue_id, "update")
+
+
+def reject_venue(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ venue_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ if venue.status not in (VenueStatus.pending_approval, VenueStatus.approved):
+ raise ConflictError("Venue cannot be rejected in its current state")
+ venue.status = VenueStatus.rejected
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="venue_rejected",
+ target_type="venue", target_id=venue_id, reason=reason or None,
+ ))
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(
+ db, venue.owner_id, NotificationType.VENUE_REJECTED,
+ context={"venue_name": venue.name, "reason": reason or "No reason provided."},
+ )
+
+ db.commit()
+
+
+def suspend_venue(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ venue_id: uuid.UUID,
+ reason: str,
+) -> None:
+ if not reason.strip():
+ raise ConflictError("Reason is required to suspend a venue")
+ venue = _get_venue_or_404(db, venue_id)
+ if venue.status == VenueStatus.suspended:
+ raise ConflictError("Venue is already suspended")
+ if venue.status != VenueStatus.approved:
+ raise ConflictError("Only approved venues can be suspended")
+ _check_no_active_bookings(db, venue_id)
+ venue.status = VenueStatus.suspended
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="venue_suspended",
+ target_type="venue", target_id=venue_id, reason=reason,
+ ))
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(
+ db, venue.owner_id, NotificationType.VENUE_SUSPENDED,
+ context={"venue_name": venue.name, "reason": reason},
+ )
+
+ db.commit()
+
+
+def reactivate_venue(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ venue_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ if venue.status not in (VenueStatus.suspended, VenueStatus.rejected):
+ raise ConflictError("Only suspended or rejected venues can be reactivated")
+ venue.status = VenueStatus.approved
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="venue_reactivated",
+ target_type="venue", target_id=venue_id, reason=reason or None,
+ ))
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(
+ db, venue.owner_id, NotificationType.VENUE_REACTIVATED,
+ context={"venue_name": venue.name},
+ )
+
+ db.commit()
+
+ from app.modules.search.indexer import enqueue_job
+ enqueue_job(db, venue_id, "update")
+
+
+def approve_owner(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ user_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise NotFoundError("User not found")
+ if profile.status != ProfileStatus.pending:
+ raise ConflictError("User is not in pending status")
+
+ profile.status = ProfileStatus.active
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="venue_owner_approved",
+ target_type="user",
+ target_id=user_id,
+ reason=reason or None,
+ ))
+ db.commit()
+
+ # Best-effort — email delivery must never undo the approval above, which
+ # already succeeded and is committed by this point.
+ if profile.email:
+ try:
+ from app.core.email import send_email
+ from app.modules.notification.templates import render_owner_approved_email
+
+ subject, html = render_owner_approved_email(profile.full_name)
+ send_email(profile.email, subject, html)
+ except Exception:
+ logger.warning("Could not email owner-approved notice to %s", profile.email, exc_info=True)
+
+
+def reject_owner(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ user_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise NotFoundError("User not found")
+ if profile.status != ProfileStatus.pending:
+ raise ConflictError("User is not in pending status")
+
+ profile.status = ProfileStatus.rejected
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="venue_owner_rejected",
+ target_type="user",
+ target_id=user_id,
+ reason=reason or None,
+ ))
+ db.commit()
+
+ # Best-effort — email delivery must never undo the rejection above, which
+ # already succeeded and is committed by this point.
+ if profile.email:
+ try:
+ from app.core.email import send_email
+ from app.modules.notification.templates import render_owner_rejected_email
+
+ subject, html = render_owner_rejected_email(profile.full_name, reason)
+ send_email(profile.email, subject, html)
+ except Exception:
+ logger.warning("Could not email owner-rejected notice to %s", profile.email, exc_info=True)
+
+
+def _build_user_dict(
+ profile: Profile,
+ roles: list[str],
+ email: str | None,
+) -> dict:
+ return {
+ "id": profile.id,
+ "full_name": profile.full_name,
+ "email": email,
+ "phone": profile.phone,
+ "status": profile.status.value,
+ "roles": roles,
+ "created_at": profile.created_at,
+ "is_super_admin": "super_admin" in roles,
+ }
+
+
+def get_owner_stats(db: Session) -> dict:
+ owner_subq = (
+ db.query(UserRoleAssignment.user_id)
+ .filter(UserRoleAssignment.role == UserRole.venue_owner)
+ .scalar_subquery()
+ )
+ base = db.query(Profile).filter(
+ Profile.deleted_at.is_(None),
+ Profile.id.in_(owner_subq),
+ )
+ row = base.with_entities(
+ func.count(Profile.id).label("total"),
+ func.count(case((Profile.status == ProfileStatus.pending, 1))).label("pending"),
+ func.count(case((Profile.status == ProfileStatus.active, 1))).label("active"),
+ func.count(case((Profile.status == ProfileStatus.rejected, 1))).label("rejected"),
+ func.count(case((Profile.status == ProfileStatus.suspended, 1))).label("suspended"),
+ ).one()
+ return {
+ "total": row.total,
+ "pending": row.pending,
+ "active": row.active,
+ "rejected": row.rejected,
+ "suspended": row.suspended,
+ }
+
+
+def list_users(
+ db: Session,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ search: str | None = None,
+ status: str | None = None,
+ role: str | None = None,
+) -> dict:
+ base = db.query(Profile).filter(Profile.deleted_at.is_(None))
+
+ filtered = base
+ if search:
+ safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ pattern = f"%{safe}%"
+ filtered = filtered.filter(
+ Profile.full_name.ilike(pattern) | Profile.email.ilike(pattern)
+ )
+ if status:
+ filtered = filtered.filter(Profile.status == ProfileStatus(status))
+ if role:
+ role_subq = (
+ db.query(UserRoleAssignment.user_id)
+ .filter(UserRoleAssignment.role == UserRole(role))
+ .scalar_subquery()
+ )
+ filtered = filtered.filter(Profile.id.in_(role_subq))
+
+ total = filtered.with_entities(func.count(Profile.id)).scalar()
+
+ # Global stats — always reflect platform-wide counts regardless of filters
+ stats_row = base.with_entities(
+ func.count(Profile.id).label("total"),
+ func.count(case((Profile.status == ProfileStatus.active, 1))).label("active"),
+ func.count(case((Profile.status == ProfileStatus.suspended, 1))).label("suspended"),
+ func.count(case((Profile.status == ProfileStatus.pending, 1))).label("pending"),
+ func.count(case((Profile.status == ProfileStatus.rejected, 1))).label("rejected"),
+ ).one()
+
+ profiles = (
+ filtered.order_by(Profile.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ .all()
+ )
+
+ profile_ids = [p.id for p in profiles]
+
+ role_rows = (
+ db.query(UserRoleAssignment)
+ .filter(UserRoleAssignment.user_id.in_(profile_ids))
+ .all()
+ )
+ roles_by_user: dict[uuid.UUID, list[str]] = {pid: [] for pid in profile_ids}
+ for rr in role_rows:
+ if rr.user_id in roles_by_user:
+ roles_by_user[rr.user_id].append(rr.role.value)
+
+ items = [
+ _build_user_dict(p, roles_by_user.get(p.id, []), p.email)
+ for p in profiles
+ ]
+
+ return {
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": math.ceil(total / page_size) if total else 1,
+ "stats": {
+ "total": stats_row.total,
+ "active": stats_row.active,
+ "suspended": stats_row.suspended,
+ "pending": stats_row.pending,
+ "rejected": stats_row.rejected,
+ },
+ }
+
+
+def get_user(db: Session, user_id: uuid.UUID) -> dict:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise NotFoundError("User not found")
+
+ role_rows = db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == user_id,
+ ).all()
+ roles = [r.role.value for r in role_rows]
+ return _build_user_dict(profile, roles, profile.email)
+
+
+def suspend_user(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ user_id: uuid.UUID,
+ reason: str,
+) -> None:
+ if not reason.strip():
+ raise ConflictError("Reason is required")
+
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise NotFoundError("User not found")
+
+ is_super_admin = db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == user_id,
+ UserRoleAssignment.role == UserRole.super_admin,
+ ).first()
+ if is_super_admin:
+ raise ForbiddenError("Super admin accounts cannot be suspended")
+
+ if profile.status == ProfileStatus.suspended:
+ raise ConflictError("User is already suspended")
+
+ profile.status = ProfileStatus.suspended
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="user_suspended",
+ target_type="user",
+ target_id=user_id,
+ reason=reason,
+ ))
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(
+ db, user_id, NotificationType.USER_SUSPENDED,
+ context={"reason": reason},
+ )
+
+ db.commit()
+
+
+def reactivate_user(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ user_id: uuid.UUID,
+ reason: str = "",
+) -> None:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise NotFoundError("User not found")
+
+ if profile.status == ProfileStatus.active:
+ raise ConflictError("User is already active")
+
+ profile.status = ProfileStatus.active
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="user_reactivated",
+ target_type="user",
+ target_id=user_id,
+ reason=reason,
+ ))
+
+ from app.modules.notification import service as notifications
+ from app.modules.notification.types import NotificationType
+ notifications.notify(db, user_id, NotificationType.USER_REACTIVATED)
+
+ db.commit()
+
+
+def _get_amenity_or_404(db: Session, amenity_id: uuid.UUID) -> Amenity:
+ amenity = db.query(Amenity).filter(Amenity.id == amenity_id).first()
+ if not amenity:
+ raise NotFoundError("Amenity not found")
+ return amenity
+
+
+def _count_active_venues(db: Session, amenity_id: uuid.UUID) -> int:
+ return db.query(VenueAmenity).filter(VenueAmenity.amenity_id == amenity_id).count()
+
+
+def _amenity_to_dict(amenity: Amenity, active_venue_count: int) -> dict:
+ return {
+ "id": amenity.id,
+ "name": amenity.name,
+ "icon": amenity.icon,
+ "created_at": amenity.created_at,
+ "deleted_at": amenity.deleted_at,
+ "active_venue_count": active_venue_count,
+ }
+
+
+def list_amenities(db: Session, *, include_deleted: bool = False) -> dict:
+ query = db.query(Amenity)
+ if not include_deleted:
+ query = query.filter(Amenity.deleted_at.is_(None))
+
+ amenities = query.order_by(Amenity.name.asc()).all()
+ amenity_ids = [a.id for a in amenities]
+
+ count_rows = (
+ db.query(VenueAmenity.amenity_id, func.count(VenueAmenity.venue_id).label("cnt"))
+ .filter(VenueAmenity.amenity_id.in_(amenity_ids))
+ .group_by(VenueAmenity.amenity_id)
+ .all()
+ ) if amenity_ids else []
+ counts = {row.amenity_id: row.cnt for row in count_rows}
+
+ items = [_amenity_to_dict(a, counts.get(a.id, 0)) for a in amenities]
+ return {"items": items, "total": len(items)}
+
+
+def create_amenity(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ name: str,
+ icon: str | None,
+) -> dict:
+ normalized = name.strip()
+ # Serialize concurrent creates for the same name — prevents two transactions
+ # from both reading "not found" and both inserting, which DB constraints alone can't stop.
+ db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:n))"), {"n": normalized.lower()})
+ amenity = Amenity(name=normalized, icon=icon)
+ db.add(amenity)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ raise ConflictError("An amenity with this name already exists")
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="amenity_created",
+ target_type="amenity",
+ target_id=amenity.id,
+ ))
+ db.commit()
+ db.refresh(amenity)
+ return _amenity_to_dict(amenity, 0)
+
+
+def update_amenity(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ amenity_id: uuid.UUID,
+ body: AmenityUpdateRequest,
+) -> dict:
+ amenity = _get_amenity_or_404(db, amenity_id)
+ if amenity.deleted_at is not None:
+ raise ConflictError("Cannot update a deleted amenity")
+
+ if "name" in body.model_fields_set and body.name is not None:
+ normalized = body.name.strip()
+ db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:n))"), {"n": normalized.lower()})
+ amenity.name = normalized
+ if "icon" in body.model_fields_set:
+ amenity.icon = body.icon
+
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ raise ConflictError("An amenity with this name already exists")
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="amenity_updated",
+ target_type="amenity",
+ target_id=amenity.id,
+ ))
+ db.commit()
+ db.refresh(amenity)
+ active_count = _count_active_venues(db, amenity.id)
+ return _amenity_to_dict(amenity, active_count)
+
+
+def delete_amenity(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ amenity_id: uuid.UUID,
+) -> dict:
+ amenity = _get_amenity_or_404(db, amenity_id)
+ if amenity.deleted_at is not None:
+ raise ConflictError("Amenity is already deleted")
+
+ active_count = _count_active_venues(db, amenity.id)
+ amenity.deleted_at = datetime.now(timezone.utc)
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="amenity_deleted",
+ target_type="amenity",
+ target_id=amenity.id,
+ ))
+ db.commit()
+ return {"deleted": True, "active_venue_count": active_count}
+
+
+def _get_category_or_404(db: Session, category_id: uuid.UUID) -> VenueCategory:
+ cat = db.query(VenueCategory).filter(VenueCategory.id == category_id).first()
+ if not cat:
+ raise NotFoundError("Category not found")
+ return cat
+
+
+def _count_category_venues(db: Session, category_id: uuid.UUID) -> int:
+ return db.query(Venue).filter(Venue.category_id == category_id, Venue.deleted_at.is_(None)).count()
+
+
+def _category_to_dict(cat: VenueCategory, venue_count: int) -> dict:
+ return {
+ "id": cat.id,
+ "slug": cat.slug,
+ "label": cat.label,
+ "icon": cat.icon,
+ "banner_image": cat.banner_image,
+ "is_active": cat.is_active,
+ "sort_order": cat.sort_order,
+ "created_at": cat.created_at,
+ "deleted_at": cat.deleted_at,
+ "venue_count": venue_count,
+ }
+
+
+def list_categories(db: Session, *, include_deleted: bool = False) -> dict:
+ query = db.query(VenueCategory)
+ if not include_deleted:
+ query = query.filter(VenueCategory.deleted_at.is_(None))
+ cats = query.order_by(VenueCategory.sort_order.asc(), VenueCategory.label.asc()).all()
+
+ cat_ids = [c.id for c in cats]
+ count_rows = (
+ db.query(Venue.category_id, func.count(Venue.id).label("cnt"))
+ .filter(Venue.category_id.in_(cat_ids), Venue.deleted_at.is_(None))
+ .group_by(Venue.category_id)
+ .all()
+ ) if cat_ids else []
+ counts = {row.category_id: row.cnt for row in count_rows}
+
+ items = [_category_to_dict(c, counts.get(c.id, 0)) for c in cats]
+ return {"items": items, "total": len(items)}
+
+
+def create_category(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ slug: str,
+ label: str,
+ icon: str | None,
+ sort_order: int,
+) -> dict:
+ normalized_slug = slug.strip().lower()
+ db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:s))"), {"s": normalized_slug})
+ cat = VenueCategory(slug=normalized_slug, label=label.strip(), icon=icon, sort_order=sort_order)
+ db.add(cat)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ raise ConflictError("A category with this slug already exists")
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="category_created",
+ target_type="category",
+ target_id=cat.id,
+ ))
+ db.commit()
+ db.refresh(cat)
+ return _category_to_dict(cat, 0)
+
+
+def update_category(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ category_id: uuid.UUID,
+ body: CategoryUpdateRequest,
+) -> dict:
+ cat = _get_category_or_404(db, category_id)
+ if cat.deleted_at is not None:
+ raise ConflictError("Cannot update a deleted category")
+
+ if "label" in body.model_fields_set and body.label is not None:
+ cat.label = body.label.strip()
+ if "icon" in body.model_fields_set:
+ cat.icon = body.icon
+ if "sort_order" in body.model_fields_set and body.sort_order is not None:
+ cat.sort_order = body.sort_order
+ if "is_active" in body.model_fields_set and body.is_active is not None:
+ cat.is_active = body.is_active
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="category_updated",
+ target_type="category",
+ target_id=cat.id,
+ ))
+ db.commit()
+ db.refresh(cat)
+ venue_count = _count_category_venues(db, cat.id)
+ return _category_to_dict(cat, venue_count)
+
+
+def upload_category_banner(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ category_id: uuid.UUID,
+ file_bytes: bytes,
+) -> dict:
+ cat = _get_category_or_404(db, category_id)
+ if cat.deleted_at is not None:
+ raise ConflictError("Cannot update a deleted category")
+
+ if cat.banner_image:
+ try:
+ delete_image_from_cloudinary(cat.banner_image)
+ except Exception:
+ pass
+
+ banner_url = upload_image_to_cloudinary(file_bytes, folder=f"categories/{category_id}")
+ cat.banner_image = banner_url
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="category_banner_updated",
+ target_type="category",
+ target_id=cat.id,
+ ))
+ db.commit()
+ db.refresh(cat)
+ return {"banner_image": cat.banner_image}
+
+
+def delete_category_banner(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ category_id: uuid.UUID,
+) -> dict:
+ cat = _get_category_or_404(db, category_id)
+ if cat.banner_image:
+ try:
+ delete_image_from_cloudinary(cat.banner_image)
+ except Exception:
+ pass
+ cat.banner_image = None
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="category_banner_deleted",
+ target_type="category",
+ target_id=cat.id,
+ ))
+ db.commit()
+ return {"banner_image": None}
+
+
+def delete_category(
+ db: Session,
+ *,
+ admin_id: uuid.UUID,
+ category_id: uuid.UUID,
+) -> dict:
+ cat = _get_category_or_404(db, category_id)
+ if cat.deleted_at is not None:
+ raise ConflictError("Category is already deleted")
+
+ venue_count = _count_category_venues(db, cat.id)
+ cat.deleted_at = datetime.now(timezone.utc)
+ cat.is_active = False
+
+ db.add(AdminAction(
+ admin_id=admin_id,
+ action_type="category_deleted",
+ target_type="category",
+ target_id=cat.id,
+ ))
+ db.commit()
+ return {"deleted": True, "venue_count": venue_count}
+
+
+def list_actions(
+ db: Session,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ target_type: str | None = None,
+ action_type: str | None = None,
+ # Legacy — used by dashboard summary (returns at most `limit` items, page 1)
+ limit: int | None = None,
+) -> dict:
+ query = db.query(AdminAction)
+ if target_type:
+ query = query.filter(AdminAction.target_type == target_type)
+ if action_type:
+ query = query.filter(AdminAction.action_type == action_type)
+
+ total = query.with_entities(func.count(AdminAction.id)).scalar()
+
+ effective_page_size = limit if limit is not None else page_size
+ offset = 0 if limit is not None else (page - 1) * page_size
+
+ items = (
+ query
+ .order_by(AdminAction.created_at.desc())
+ .offset(offset)
+ .limit(effective_page_size)
+ .all()
+ )
+
+ # Batch-enrich admin names
+ admin_ids = list({a.admin_id for a in items})
+ admin_profiles = db.query(Profile).filter(Profile.id.in_(admin_ids)).all()
+ admin_name_by_id = {p.id: p.full_name for p in admin_profiles}
+
+ # Batch-resolve target names grouped by target_type
+ target_name_by_id: dict[uuid.UUID, str] = {}
+
+ user_ids = [a.target_id for a in items if a.target_type == "user"]
+ if user_ids:
+ rows = db.query(Profile.id, Profile.full_name).filter(Profile.id.in_(user_ids)).all()
+ target_name_by_id.update({r.id: r.full_name for r in rows if r.full_name})
+
+ venue_ids = [a.target_id for a in items if a.target_type == "venue"]
+ if venue_ids:
+ rows = db.query(Venue.id, Venue.name).filter(Venue.id.in_(venue_ids)).all()
+ target_name_by_id.update({r.id: r.name for r in rows})
+
+ amenity_ids = [a.target_id for a in items if a.target_type == "amenity"]
+ if amenity_ids:
+ rows = db.query(Amenity.id, Amenity.name).filter(Amenity.id.in_(amenity_ids)).all()
+ target_name_by_id.update({r.id: r.name for r in rows})
+
+ enriched = [
+ {
+ "id": a.id,
+ "admin_id": a.admin_id,
+ "admin_name": admin_name_by_id.get(a.admin_id),
+ "action_type": a.action_type,
+ "target_type": a.target_type,
+ "target_id": a.target_id,
+ "target_name": target_name_by_id.get(a.target_id),
+ "reason": a.reason,
+ "created_at": a.created_at,
+ }
+ for a in items
+ ]
+
+ return {
+ "items": enriched,
+ "total": total,
+ "page": 1 if limit is not None else page,
+ "page_size": effective_page_size,
+ "total_pages": math.ceil(total / effective_page_size) if total else 1,
+ }
+
+
+def seed_super_admin() -> None:
+ """
+ Idempotent. Creates the super admin user in Supabase on first run.
+ Skipped entirely if SUPER_ADMIN_EMAIL or SUPER_ADMIN_PASSWORD
+ are not set in the environment.
+ """
+ email = settings.super_admin_email
+ password = settings.super_admin_password
+ name = settings.super_admin_name
+
+ if not email or not password:
+ logger.info("SUPER_ADMIN_EMAIL/PASSWORD not set, skipping super admin seed")
+ return
+
+ db: Session = SessionLocal()
+ try:
+ _seed(db, email, password, name)
+ finally:
+ db.close()
+
+
+def _seed(db: Session, email: str, password: str, name: str) -> None:
+ auth_user_id = _resolve_supabase_user(email, password, name)
+
+ profile = db.query(Profile).filter(Profile.id == auth_user_id).first()
+ if not profile:
+ profile = Profile(
+ id=auth_user_id,
+ full_name=name or "Super Admin",
+ status=ProfileStatus.active,
+ )
+ db.add(profile)
+ db.flush()
+ logger.info("Created profile for super admin %s", email)
+ elif name and profile.full_name != name:
+ profile.full_name = name
+ db.flush()
+ logger.info("Updated full_name for super admin %s", email)
+
+ role_exists = db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == auth_user_id,
+ UserRoleAssignment.role == UserRole.super_admin,
+ ).first()
+
+ if not role_exists:
+ db.add(UserRoleAssignment(user_id=auth_user_id, role=UserRole.super_admin))
+ logger.info("Assigned super_admin role to %s", email)
+
+ db.commit()
+ logger.info("Super admin seed complete for %s", email)
+
+
+def _supabase_admin_request(method: str, path: str, body: dict | None = None) -> dict:
+ """Makes a request to the Supabase Auth Admin REST API."""
+ url = f"{settings.supabase_url}/auth/v1/admin/{path}"
+ data = json.dumps(body).encode() if body else None
+ req = urllib.request.Request(
+ url,
+ data=data,
+ method=method,
+ headers={
+ "Authorization": f"Bearer {settings.supabase_service_role_key}",
+ "apikey": settings.supabase_service_role_key,
+ "Content-Type": "application/json",
+ },
+ )
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
+ return json.loads(resp.read())
+
+
+def _resolve_supabase_user(email: str, password: str, name: str = "") -> uuid.UUID:
+ """
+ Returns the UUID of the Supabase auth user, creating them if they
+ don't exist. Uses the Admin API so no confirmation email is triggered.
+ """
+ # List users and search by email
+ page = 1
+ per_page = 1000
+ while True:
+ data = _supabase_admin_request("GET", f"users?page={page}&per_page={per_page}")
+ users = data.get("users", [])
+ for u in users:
+ if u.get("email") == email:
+ logger.info("Super admin already exists in Supabase: %s", email)
+ return uuid.UUID(u["id"])
+ if len(users) < per_page:
+ break
+ page += 1
+
+ # Not found — create via Admin API (email_confirm skips verification email)
+ result = _supabase_admin_request("POST", "users", {
+ "email": email,
+ "password": password,
+ "email_confirm": True,
+ "user_metadata": {"full_name": name or "Super Admin"},
+ })
+ logger.info("Created Supabase auth user for super admin: %s", email)
+ return uuid.UUID(result["id"])
+
+
+# ─── Booking admin service ─────────────────────────────────────────────────────
+
+_CANCELLED_STATUSES = (
+ BookingStatus.hold_expired,
+ BookingStatus.request_expired,
+ BookingStatus.conflict_cancelled,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.owner_rejected,
+ BookingStatus.balance_overdue_cancelled,
+)
+
+
+def get_booking_stats(db: Session) -> dict:
+ row = db.query(
+ func.count(Booking.id).label("total"),
+ func.count(case((Booking.status == BookingStatus.requested, 1))).label("requested"),
+ func.count(case((Booking.status == BookingStatus.confirmed, 1))).label("confirmed"),
+ func.count(case((Booking.status == BookingStatus.completed, 1))).label("completed"),
+ func.count(case((Booking.status.in_(_CANCELLED_STATUSES), 1))).label("cancelled"),
+ ).filter(Booking.deleted_at.is_(None)).one()
+ return {
+ "total": row.total,
+ "requested": row.requested,
+ "confirmed": row.confirmed,
+ "completed": row.completed,
+ "cancelled": row.cancelled,
+ }
+
+
+def list_admin_bookings(
+ db: Session,
+ *,
+ status: str | None = None,
+ search: str | None = None,
+ page: int = 1,
+ page_size: int = 25,
+) -> dict:
+ stats = get_booking_stats(db)
+
+ base = (
+ db.query(Booking)
+ .join(Venue, Venue.id == Booking.venue_id)
+ .join(Profile, Profile.id == Booking.user_id)
+ .filter(Booking.deleted_at.is_(None))
+ )
+
+ if status:
+ if status == "cancelled":
+ base = base.filter(Booking.status.in_(_CANCELLED_STATUSES))
+ else:
+ base = base.filter(Booking.status == BookingStatus(status))
+
+ if search:
+ safe = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ base = base.filter(Venue.name.ilike(f"%{safe}%") | Profile.full_name.ilike(f"%{safe}%"))
+
+ total = base.count()
+ total_pages = max(1, math.ceil(total / page_size))
+ rows = (
+ base
+ .order_by(Booking.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ .all()
+ )
+
+ slot_map: dict = {}
+ owner_map: dict = {}
+ if rows:
+ booking_ids = [b.id for b in rows]
+ slots = db.query(BookingSlot).filter(BookingSlot.booking_id.in_(booking_ids)).all()
+ slot_map = {s.booking_id: s for s in slots}
+
+ owner_ids = list({b.venue.owner_id for b in rows})
+ owners = db.query(Profile).filter(Profile.id.in_(owner_ids)).all()
+ owner_map = {o.id: o for o in owners}
+
+ items: list[dict] = []
+ for b in rows:
+ slot = slot_map.get(b.id)
+ event_date = slot.starts_at.date().isoformat() if slot else ""
+ owner = owner_map.get(b.venue.owner_id)
+ items.append({
+ "id": b.id,
+ "venue_id": b.venue_id,
+ "venue_name": b.venue.name,
+ "customer_name": b.user.full_name,
+ "customer_email": b.user.email,
+ "customer_phone": b.user.phone,
+ "owner_id": b.venue.owner_id,
+ "owner_name": owner.full_name if owner else None,
+ "owner_email": owner.email if owner else None,
+ "owner_phone": owner.phone if owner else None,
+ "status": b.status.value,
+ "payment_status": b.payment_status.value,
+ "event_date": event_date,
+ "guest_count": b.guest_count,
+ "created_at": b.created_at,
+ })
+
+ return {
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": total_pages,
+ "stats": stats,
+ }
+
+
+# ─── Deep Research observability ───────────────────────────────────────────────
+# Read-only reporting over deep_research.models.DeepResearchQuery. No
+# admin_actions audit entries here — nothing mutates state (contrast with
+# Phase 3's lead/reservation admin endpoints, which will need audit logging).
+
+
+def list_deep_research_queries(
+ db: Session, page: int, page_size: int, search: str | None = None
+) -> DeepResearchQueryListResponse:
+ q = db.query(DeepResearchQuery)
+ if search:
+ q = q.filter(DeepResearchQuery.query_text.ilike(f"%{search}%"))
+
+ total = q.count()
+ rows = (
+ q.order_by(DeepResearchQuery.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ .all()
+ )
+
+ return DeepResearchQueryListResponse(
+ items=[DeepResearchQuerySummary.model_validate(r) for r in rows],
+ total=total,
+ page=page,
+ page_size=page_size,
+ )
+
+
+def get_deep_research_query(db: Session, query_id: uuid.UUID) -> DeepResearchQueryDetail:
+ row = db.query(DeepResearchQuery).filter(DeepResearchQuery.id == query_id).first()
+ if not row:
+ raise NotFoundError("Deep research query not found")
+ return DeepResearchQueryDetail.model_validate(row)
+
+
+def get_deep_research_stats(db: Session, days: int = 30) -> DeepResearchStatsResponse:
+ now = datetime.now(timezone.utc)
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ period_start = today_start - timedelta(days=days - 1)
+
+ rows = (
+ db.query(
+ func.date_trunc("day", DeepResearchQuery.created_at).label("bucket"),
+ func.count(DeepResearchQuery.id).label("cnt"),
+ func.avg(DeepResearchQuery.avg_match_score).label("avg_score"),
+ )
+ .filter(DeepResearchQuery.created_at >= period_start)
+ .group_by("bucket")
+ .all()
+ )
+ by_bucket = {r.bucket.replace(tzinfo=timezone.utc): r for r in rows}
+
+ labels: list[str] = []
+ counts: list[int] = []
+ avg_scores: list[float | None] = []
+ for i in range(days - 1, -1, -1):
+ day = today_start - timedelta(days=i)
+ row = by_bucket.get(day)
+ labels.append(f"{day.day} {day.strftime('%b')}")
+ counts.append(row.cnt if row else 0)
+ avg_scores.append(float(row.avg_score) if row and row.avg_score is not None else None)
+
+ total_queries = db.query(func.count(DeepResearchQuery.id)).scalar() or 0
+ avg_result_count = db.query(func.avg(DeepResearchQuery.result_count)).scalar()
+ avg_match_score_overall = db.query(func.avg(DeepResearchQuery.avg_match_score)).scalar()
+
+ return DeepResearchStatsResponse(
+ labels=labels,
+ query_counts=counts,
+ avg_match_scores=avg_scores,
+ total_queries=total_queries,
+ avg_result_count=float(avg_result_count) if avg_result_count is not None else 0.0,
+ avg_match_score_overall=(
+ float(avg_match_score_overall) if avg_match_score_overall is not None else None
+ ),
+ )
diff --git a/apps/api/app/modules/auth/__init__.py b/apps/api/app/modules/auth/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/auth/dependencies.py b/apps/api/app/modules/auth/dependencies.py
new file mode 100644
index 000000000..6abb6f7e4
--- /dev/null
+++ b/apps/api/app/modules/auth/dependencies.py
@@ -0,0 +1,151 @@
+from dataclasses import dataclass
+from uuid import UUID
+from fastapi import Depends, Header
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.exceptions import UnauthorizedError, ForbiddenError
+from app.modules.auth.providers.supabase import SupabaseAuthProvider
+from app.modules.profile.models import Profile, UserRole, UserRoleAssignment, ProfileStatus
+
+_auth_provider = SupabaseAuthProvider()
+
+
+def get_auth_provider():
+ """Provider-agnostic accessor — business modules must go through this
+ instead of importing SupabaseAuthProvider directly."""
+ return _auth_provider
+
+
+@dataclass
+class AuthContext:
+ user_id: UUID
+ email: str | None
+ full_name: str | None
+ phone: str | None
+ avatar_url: str | None
+ status: str
+ roles: list[str]
+
+ def has_role(self, role: str) -> bool:
+ return role in self.roles
+
+ def is_admin(self) -> bool:
+ return "super_admin" in self.roles
+
+ def is_owner(self) -> bool:
+ return "venue_owner" in self.roles or self.is_admin()
+
+
+def _extract_bearer_token(authorization: str) -> str:
+ parts = authorization.split()
+ if len(parts) != 2 or parts[0].lower() != "bearer":
+ raise UnauthorizedError("Invalid authorization header")
+ return parts[1]
+
+
+def get_current_user(
+ authorization: str = Header(...),
+ db: Session = Depends(get_db),
+) -> AuthContext:
+ token = _extract_bearer_token(authorization)
+ provider_user = _auth_provider.verify_token(token)
+
+ profile = db.query(Profile).filter(
+ Profile.id == provider_user.id,
+ Profile.deleted_at.is_(None),
+ ).first()
+
+ if not profile:
+ raise ForbiddenError("Account not found")
+
+ if profile.status == ProfileStatus.suspended:
+ raise ForbiddenError("Account suspended")
+
+ # Keep email in profiles in sync with the authoritative JWT value
+ if provider_user.email and profile.email != provider_user.email:
+ profile.email = provider_user.email
+
+ role_rows = db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == provider_user.id
+ ).all()
+
+ roles = [r.role.value for r in role_rows]
+
+ # Every account carries 'customer' as a baseline role (see CLAUDE.md role
+ # model). Accounts created before the signup trigger existed, or via
+ # out-of-band inserts (admin scripts, direct SQL), may be missing it —
+ # self-heal here so login works without a manual backfill.
+ if UserRole.customer.value not in roles:
+ db.add(UserRoleAssignment(user_id=profile.id, role=UserRole.customer))
+ db.commit()
+ roles.append(UserRole.customer.value)
+
+ return AuthContext(
+ user_id=profile.id,
+ email=provider_user.email,
+ full_name=profile.full_name,
+ phone=profile.phone,
+ avatar_url=profile.avatar_url,
+ status=profile.status.value,
+ roles=roles,
+ )
+
+
+def get_current_user_optional(
+ authorization: str | None = Header(default=None),
+ db: Session = Depends(get_db),
+) -> AuthContext | None:
+ if not authorization:
+ return None
+ try:
+ return get_current_user(authorization, db)
+ except Exception:
+ return None
+
+
+def require_auth(
+ current_user: AuthContext = Depends(get_current_user),
+) -> AuthContext:
+ return current_user
+
+
+def require_role(*roles: str):
+ def dependency(
+ current_user: AuthContext = Depends(get_current_user),
+ ) -> AuthContext:
+ if not any(r in current_user.roles for r in roles):
+ raise ForbiddenError("Insufficient permissions")
+ return current_user
+ return dependency
+
+
+def require_any_role(roles: list[str]):
+ def dependency(
+ current_user: AuthContext = Depends(get_current_user),
+ ) -> AuthContext:
+ if not any(r in current_user.roles for r in roles):
+ raise ForbiddenError("Insufficient permissions")
+ return current_user
+ return dependency
+
+
+def require_admin(
+ current_user: AuthContext = Depends(get_current_user),
+) -> AuthContext:
+ if not current_user.is_admin():
+ raise ForbiddenError("Admin access required")
+ return current_user
+
+
+def require_owner(
+ current_user: AuthContext = Depends(get_current_user),
+) -> AuthContext:
+ if not current_user.is_owner():
+ raise ForbiddenError("Venue owner access required")
+ if not current_user.is_admin():
+ if current_user.status == "pending":
+ raise ForbiddenError("account_pending")
+ if current_user.status == "rejected":
+ raise ForbiddenError("account_rejected")
+ return current_user
diff --git a/apps/api/app/modules/auth/providers/__init__.py b/apps/api/app/modules/auth/providers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/auth/providers/base.py b/apps/api/app/modules/auth/providers/base.py
new file mode 100644
index 000000000..257353cb8
--- /dev/null
+++ b/apps/api/app/modules/auth/providers/base.py
@@ -0,0 +1,46 @@
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from uuid import UUID
+
+
+@dataclass
+class ProviderUser:
+ id: UUID
+ email: str | None
+
+
+class AuthProvider(ABC):
+ @abstractmethod
+ def verify_token(self, token: str) -> ProviderUser:
+ """Verify the token and return a normalized ProviderUser.
+ Raise UnauthorizedError if the token is invalid or expired.
+ """
+ ...
+
+ @abstractmethod
+ def create_invite_link(
+ self,
+ email: str,
+ *,
+ full_name: str | None = None,
+ phone: str | None = None,
+ redirect_to: str | None = None,
+ ) -> tuple[ProviderUser, str]:
+ """Create an account for `email` in invited state and return
+ (user, action_link) — a one-time secure account-setup URL. Does NOT
+ send any email; the caller is responsible for delivering the link
+ (this project emails it via its own Resend pipeline rather than the
+ provider's, since the provider's default email sending requires a
+ verified sending domain this project doesn't have). Raise
+ ConflictError if the email is already registered.
+ """
+ ...
+
+ @abstractmethod
+ def create_recovery_link(self, email: str, *, redirect_to: str | None = None) -> str | None:
+ """Generate a one-time password-recovery URL for an existing
+ account. Does NOT send any email. Returns None if no account exists
+ for `email` — callers must still respond as if it succeeded, to
+ avoid leaking which emails are registered.
+ """
+ ...
diff --git a/apps/api/app/modules/auth/providers/supabase.py b/apps/api/app/modules/auth/providers/supabase.py
new file mode 100644
index 000000000..fe695a3c0
--- /dev/null
+++ b/apps/api/app/modules/auth/providers/supabase.py
@@ -0,0 +1,122 @@
+import json
+import urllib.error
+import urllib.request
+from uuid import UUID
+
+from jose import jwt, JWTError
+
+from app.core.config import settings
+from app.core.exceptions import BadRequestError, ConflictError, UnauthorizedError
+from app.modules.auth.providers.base import AuthProvider, ProviderUser
+
+
+class SupabaseAuthProvider(AuthProvider):
+ def __init__(self) -> None:
+ self._jwks_cache: list | None = None
+
+ def _get_jwks(self) -> list:
+ if self._jwks_cache is None:
+ url = f"{settings.supabase_url}/auth/v1/.well-known/jwks.json"
+ with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
+ self._jwks_cache = json.loads(resp.read()).get("keys", [])
+ return self._jwks_cache
+
+ def verify_token(self, token: str) -> ProviderUser:
+ try:
+ header = jwt.get_unverified_header(token)
+ alg = header.get("alg", "HS256")
+
+ if alg == "HS256":
+ payload = jwt.decode(
+ token,
+ settings.supabase_jwt_secret,
+ algorithms=["HS256"],
+ audience="authenticated",
+ )
+ else:
+ # RS256 or other asymmetric alg — verify via Supabase JWKS
+ kid = header.get("kid")
+ keys = self._get_jwks()
+ key = next((k for k in keys if k.get("kid") == kid), None) or (keys[0] if keys else None)
+ if key is None:
+ raise UnauthorizedError("No matching JWKS key found")
+ payload = jwt.decode(
+ token,
+ key,
+ algorithms=[alg],
+ audience="authenticated",
+ )
+ except JWTError as exc:
+ raise UnauthorizedError(f"JWT error: {exc}")
+ except (urllib.error.URLError, TimeoutError) as exc:
+ # JWKS fetch failed (network blip, Supabase briefly unreachable, etc.) —
+ # this must surface as a normal 401, not an uncaught 500 that takes
+ # every authenticated request down with it. Cache stays empty, so the
+ # next request retries the fetch on its own.
+ raise UnauthorizedError(f"Could not verify token — key lookup failed: {exc}")
+
+ sub = payload.get("sub")
+ if not sub:
+ raise UnauthorizedError("Token missing subject claim")
+
+ return ProviderUser(id=UUID(sub), email=payload.get("email"))
+
+ def _generate_link(self, body: dict) -> dict:
+ """POST /admin/generate_link — creates the account/token but sends no
+ email itself, unlike /invite or the client SDK's resetPasswordForEmail.
+ """
+ url = f"{settings.supabase_url}/auth/v1/admin/generate_link"
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(body).encode(),
+ method="POST",
+ headers={
+ "apikey": settings.supabase_service_role_key,
+ "Authorization": f"Bearer {settings.supabase_service_role_key}",
+ "Content-Type": "application/json",
+ },
+ )
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
+ return json.loads(resp.read())
+
+ def create_invite_link(
+ self,
+ email: str,
+ *,
+ full_name: str | None = None,
+ phone: str | None = None,
+ redirect_to: str | None = None,
+ ) -> tuple[ProviderUser, str]:
+ body = {"type": "invite", "email": email, "data": {"full_name": full_name, "phone": phone}}
+ if redirect_to:
+ body["redirect_to"] = redirect_to
+
+ try:
+ payload = self._generate_link(body)
+ except urllib.error.HTTPError as exc:
+ error_body = exc.read().decode()
+ if exc.code == 422 or "already been registered" in error_body:
+ raise ConflictError("This email is already registered")
+ raise BadRequestError(f"Supabase could not create the invite (HTTP {exc.code}): {error_body or exc.reason}")
+ except urllib.error.URLError as exc:
+ raise BadRequestError(f"Could not reach Supabase to create the invite: {exc.reason}")
+
+ user = payload.get("user", payload)
+ return ProviderUser(id=UUID(user["id"]), email=user.get("email")), payload["action_link"]
+
+ def create_recovery_link(self, email: str, *, redirect_to: str | None = None) -> str | None:
+ body = {"type": "recovery", "email": email}
+ if redirect_to:
+ body["redirect_to"] = redirect_to
+
+ try:
+ payload = self._generate_link(body)
+ except urllib.error.HTTPError as exc:
+ if exc.code in (400, 404, 422):
+ return None
+ error_body = exc.read().decode()
+ raise BadRequestError(f"Supabase could not create the reset link (HTTP {exc.code}): {error_body or exc.reason}")
+ except urllib.error.URLError as exc:
+ raise BadRequestError(f"Could not reach Supabase to create the reset link: {exc.reason}")
+
+ return payload["action_link"]
diff --git a/apps/api/app/modules/auth/routes.py b/apps/api/app/modules/auth/routes.py
new file mode 100644
index 000000000..c0a7c7e3c
--- /dev/null
+++ b/apps/api/app/modules/auth/routes.py
@@ -0,0 +1,35 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import get_current_user, require_auth, AuthContext
+from app.modules.auth.schemas import AuthMeResponse, ForgotPasswordRequest
+from app.modules.auth import service
+
+router = APIRouter()
+
+
+@router.get("/me", response_model=AuthMeResponse)
+def me(current_user: AuthContext = Depends(get_current_user)):
+ return service.get_me(current_user)
+
+
+@router.post("/forgot-password", status_code=204)
+def forgot_password(body: ForgotPasswordRequest):
+ service.request_password_reset(body.email, body.redirect_to)
+
+
+@router.post("/register-owner", status_code=204)
+def register_owner(
+ current_user: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ service.register_owner(current_user.user_id, db)
+
+
+@router.post("/reapply-owner", status_code=204)
+def reapply_owner(
+ current_user: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ service.reapply_owner(current_user.user_id, db)
diff --git a/apps/api/app/modules/auth/schemas.py b/apps/api/app/modules/auth/schemas.py
new file mode 100644
index 000000000..36550e647
--- /dev/null
+++ b/apps/api/app/modules/auth/schemas.py
@@ -0,0 +1,21 @@
+from pydantic import BaseModel
+from uuid import UUID
+
+
+class ProfileResponse(BaseModel):
+ full_name: str | None
+ phone: str | None
+ avatar_url: str | None
+ status: str
+
+
+class AuthMeResponse(BaseModel):
+ id: UUID
+ email: str | None
+ profile: ProfileResponse
+ roles: list[str]
+
+
+class ForgotPasswordRequest(BaseModel):
+ email: str
+ redirect_to: str
diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py
new file mode 100644
index 000000000..0f146df0f
--- /dev/null
+++ b/apps/api/app/modules/auth/service.py
@@ -0,0 +1,82 @@
+from sqlalchemy.orm import Session
+
+from app.core.exceptions import ForbiddenError
+from app.modules.auth.schemas import AuthMeResponse, ProfileResponse
+from app.modules.profile.models import Profile, ProfileStatus, UserRoleAssignment, UserRole
+
+
+def register_owner(user_id, db: Session) -> None:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise ForbiddenError("Account not found")
+
+ has_owner_role = db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == user_id,
+ UserRoleAssignment.role == "venue_owner",
+ ).first()
+
+ if has_owner_role and profile.status == ProfileStatus.pending:
+ return # idempotent — already registered as owner
+
+ if has_owner_role and profile.status not in (ProfileStatus.active, ProfileStatus.rejected):
+ raise ForbiddenError("Cannot register as owner in current account state")
+
+ if not has_owner_role:
+ db.add(UserRoleAssignment(user_id=user_id, role=UserRole.venue_owner))
+
+ profile.status = ProfileStatus.pending
+ db.commit()
+
+
+def reapply_owner(user_id, db: Session) -> None:
+ profile = db.query(Profile).filter(
+ Profile.id == user_id,
+ Profile.deleted_at.is_(None),
+ ).first()
+ if not profile:
+ raise ForbiddenError("Account not found")
+ if profile.status != ProfileStatus.rejected:
+ raise ForbiddenError("Only rejected accounts can re-apply")
+ profile.status = ProfileStatus.pending
+ db.commit()
+
+
+def request_password_reset(email: str, redirect_to: str) -> None:
+ """Public, unauthenticated — never reveals whether `email` is registered.
+ Generates a Supabase recovery link but sends the email ourselves (Resend),
+ since Supabase's own email sending needs a verified domain we don't have.
+ """
+ from urllib.parse import urlparse
+
+ from app.core.config import settings
+ from app.core.email import send_email
+ from app.modules.auth.dependencies import get_auth_provider
+ from app.modules.notification.templates import render_password_reset_email
+
+ origin = f"{urlparse(redirect_to).scheme}://{urlparse(redirect_to).netloc}"
+ if origin not in settings.cors_origins_list:
+ return # not one of our frontends — silently no-op, same as "email not found"
+
+ link = get_auth_provider().create_recovery_link(email, redirect_to=redirect_to)
+ if link is None:
+ return # no account for this email — don't leak that
+
+ subject, html = render_password_reset_email(link)
+ send_email(email, subject, html)
+
+
+def get_me(current_user) -> AuthMeResponse:
+ return AuthMeResponse(
+ id=current_user.user_id,
+ email=current_user.email,
+ profile=ProfileResponse(
+ full_name=current_user.full_name,
+ phone=current_user.phone,
+ avatar_url=current_user.avatar_url,
+ status=current_user.status,
+ ),
+ roles=current_user.roles,
+ )
diff --git a/apps/api/app/modules/availability/__init__.py b/apps/api/app/modules/availability/__init__.py
new file mode 100644
index 000000000..cf7c7a6bd
--- /dev/null
+++ b/apps/api/app/modules/availability/__init__.py
@@ -0,0 +1 @@
+"""Availability module package"""
diff --git a/apps/api/app/modules/availability/routes.py b/apps/api/app/modules/availability/routes.py
new file mode 100644
index 000000000..4cc654b71
--- /dev/null
+++ b/apps/api/app/modules/availability/routes.py
@@ -0,0 +1,134 @@
+from datetime import date, datetime
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import AuthContext, require_owner
+from app.modules.availability import service
+from app.modules.availability.schemas import AvailabilityResponse, CalendarResponse, ValidationResponse
+from app.modules.venue.schemas import BookingType, PricingQuote
+
+router = APIRouter()
+
+
+@router.get(
+ "/venues/{venue_id}/availability",
+ response_model=AvailabilityResponse,
+)
+def availability_for_date_query(
+ venue_id: str,
+ availability_date: date = Query(..., alias="date"),
+ booking_type: BookingType = Query(...),
+ db: Session = Depends(get_db),
+):
+ return service.get_availability_for_date(
+ db=db,
+ venue_id=venue_id,
+ booking_date=availability_date,
+ booking_type=booking_type.value,
+ )
+
+
+@router.get(
+ "/venues/{venue_id}/calendar",
+ response_model=CalendarResponse,
+)
+def calendar(
+ venue_id: UUID,
+ start_date: date = Query(...),
+ end_date: date = Query(...),
+ booking_type: BookingType = Query(...),
+ db: Session = Depends(get_db),
+):
+ return service.get_calendar(
+ db=db,
+ venue_id=venue_id,
+ start_date=start_date,
+ end_date=end_date,
+ booking_type=booking_type.value,
+ )
+
+
+@router.get(
+ "/venues/{venue_id}/calendar/owner",
+ response_model=CalendarResponse,
+)
+def owner_calendar(
+ venue_id: UUID,
+ start_date: date = Query(...),
+ end_date: date = Query(...),
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.get_owner_calendar(
+ db=db,
+ venue_id=venue_id,
+ owner_id=auth.user_id,
+ start_date=start_date,
+ end_date=end_date,
+ allow_admin=auth.is_admin(),
+ )
+
+
+@router.get(
+ "/venues/{venue_id}/date/{booking_date}",
+ response_model=AvailabilityResponse,
+)
+def availability_for_date(
+ venue_id: str,
+ booking_date: date,
+ booking_type: BookingType = Query(...),
+ db: Session = Depends(get_db),
+):
+ return service.get_availability_for_date(
+ db=db,
+ venue_id=venue_id,
+ booking_date=booking_date,
+ booking_type=booking_type.value,
+ )
+
+
+@router.get(
+ "/venues/{venue_id}/quote",
+ response_model=PricingQuote,
+)
+def pricing_quote(
+ venue_id: str,
+ starts_at: datetime = Query(...),
+ ends_at: datetime = Query(...),
+ booking_type: BookingType = Query(...),
+ db: Session = Depends(get_db),
+):
+ return service.get_pricing_quote(
+ db=db,
+ venue_id=venue_id,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_type=booking_type.value,
+ )
+
+
+@router.post(
+ "/venues/{venue_id}/validate",
+ response_model=ValidationResponse,
+)
+def validate_slot(
+ venue_id: str,
+ booking_type: BookingType,
+ starts_at: datetime | None = Query(None),
+ ends_at: datetime | None = Query(None),
+ booking_date: date | None = Query(None),
+ guest_count: int | None = Query(None, gt=0),
+ db: Session = Depends(get_db),
+):
+ return service.validate_slot(
+ db=db,
+ venue_id=venue_id,
+ booking_type=booking_type.value,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_date=booking_date,
+ guest_count=guest_count,
+ )
diff --git a/apps/api/app/modules/availability/schemas.py b/apps/api/app/modules/availability/schemas.py
new file mode 100644
index 000000000..44f156e9f
--- /dev/null
+++ b/apps/api/app/modules/availability/schemas.py
@@ -0,0 +1,77 @@
+from datetime import date, datetime, time
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+
+class OperatingWindow(BaseModel):
+ is_available: bool
+ opens_at: time | None = None
+ closes_at: time | None = None
+ spans_next_day: bool = False
+
+
+class BlockedRange(BaseModel):
+ starts_at: datetime
+ ends_at: datetime
+
+
+class AvailabilityResponse(BaseModel):
+ date: date
+ operating_window: OperatingWindow
+ blocked_slots: list[BlockedRange] = Field(default_factory=list)
+
+
+class ValidationResponse(BaseModel):
+ valid: bool
+ effective_starts_at: datetime
+ effective_ends_at: datetime
+
+
+CalendarDayStatus = Literal[
+ "available",
+ "partially_booked",
+ "fully_booked",
+ "blocked",
+ "closed",
+]
+
+
+class CalendarBlockedRange(BaseModel):
+ starts_at: datetime
+ ends_at: datetime
+ source: Literal["booking", "venue_block"]
+ reason: str | None = None
+
+
+class CalendarBookingSummary(BaseModel):
+ id: UUID
+ booking_type: str
+ status: str
+ starts_at: datetime
+ ends_at: datetime
+ effective_starts_at: datetime
+ effective_ends_at: datetime
+ is_blocking: bool
+ guest_count: int
+ event_type: str | None = None
+ user_id: UUID | None = None
+
+
+class CalendarDay(BaseModel):
+ date: date
+ operating_window: OperatingWindow
+ status: CalendarDayStatus
+ is_bookable: bool
+ available_for_full_day: bool
+ blocked_ranges: list[CalendarBlockedRange] = Field(default_factory=list)
+ bookings: list[CalendarBookingSummary] = Field(default_factory=list)
+
+
+class CalendarResponse(BaseModel):
+ venue_id: UUID
+ timezone: str
+ start_date: date
+ end_date: date
+ days: list[CalendarDay]
diff --git a/apps/api/app/modules/availability/service.py b/apps/api/app/modules/availability/service.py
new file mode 100644
index 000000000..9984241eb
--- /dev/null
+++ b/apps/api/app/modules/availability/service.py
@@ -0,0 +1,754 @@
+from datetime import date, datetime, time, timezone, timedelta
+from uuid import UUID
+from zoneinfo import ZoneInfo
+
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session, joinedload
+
+from app.modules.booking.models import BookingSlot
+from app.modules.booking.models import Booking
+from app.modules.profile.models import Profile # noqa: F401
+
+from app.modules.availability.schemas import (
+ AvailabilityResponse,
+ OperatingWindow,
+ BlockedRange,
+ ValidationResponse,
+ CalendarBlockedRange,
+ CalendarBookingSummary,
+ CalendarDay,
+ CalendarResponse,
+)
+
+from app.modules.venue.service import (
+ _get_active_venue_or_404,
+ _get_venue_or_404,
+ _assert_owner,
+ get_pricing_quote_for_slot,
+)
+from app.modules.venue.models import VenueAvailability, VenueBlockedDate
+
+
+def _to_utc(value: datetime) -> datetime:
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+
+ return value.astimezone(timezone.utc)
+
+
+def _localize(booking_date: date, value: time, tz: ZoneInfo) -> datetime:
+ return datetime.combine(booking_date, value).replace(tzinfo=tz)
+
+
+def _minute_of_day(value: datetime) -> int:
+ return (value.hour * 60) + value.minute
+
+
+def _local_day_bounds(venue, booking_date: date) -> tuple[datetime, datetime]:
+ tz = ZoneInfo(venue.timezone)
+ starts_at = datetime.combine(booking_date, datetime.min.time()).replace(tzinfo=tz)
+ ends_at = starts_at + timedelta(days=1)
+
+ return starts_at.astimezone(timezone.utc), ends_at.astimezone(timezone.utc)
+
+
+def _date_range(start_date: date, end_date: date) -> list[date]:
+ days = (end_date - start_date).days
+ return [start_date + timedelta(days=offset) for offset in range(days + 1)]
+
+
+def _has_overlap(
+ starts_at: datetime,
+ ends_at: datetime,
+ range_starts_at: datetime,
+ range_ends_at: datetime,
+) -> bool:
+ return _to_utc(starts_at) < _to_utc(range_ends_at) and _to_utc(ends_at) > _to_utc(range_starts_at)
+
+
+def _covers_range(
+ ranges: list[tuple[datetime, datetime]],
+ starts_at: datetime,
+ ends_at: datetime,
+) -> bool:
+ if not ranges:
+ return False
+
+ starts_at = _to_utc(starts_at)
+ ends_at = _to_utc(ends_at)
+ clipped = sorted(
+ (
+ (max(_to_utc(range_start), starts_at), min(_to_utc(range_end), ends_at))
+ for range_start, range_end in ranges
+ if _has_overlap(range_start, range_end, starts_at, ends_at)
+ ),
+ key=lambda item: item[0],
+ )
+
+ covered_until = starts_at
+ for range_start, range_end in clipped:
+ if range_start > covered_until:
+ return False
+ if range_end > covered_until:
+ covered_until = range_end
+ if covered_until >= ends_at:
+ return True
+
+ return False
+
+
+def compute_effective_range(
+ starts_at: datetime,
+ ends_at: datetime,
+ pre_buffer_minutes: int,
+ post_buffer_minutes: int,
+) -> tuple[datetime, datetime]:
+ return (
+ starts_at - timedelta(minutes=pre_buffer_minutes),
+ ends_at + timedelta(minutes=post_buffer_minutes),
+ )
+
+
+def resolve_operating_window(
+ venue,
+ booking_date: date,
+ db_override: VenueAvailability | None = None,
+) -> OperatingWindow:
+ if isinstance(booking_date, datetime):
+ booking_date = booking_date.date()
+
+ day = booking_date.weekday()
+
+ availability = db_override
+ if availability is None:
+ availability = next(
+ (
+ a
+ for a in getattr(venue, "availability", [])
+ if a.day_of_week == day and a.deleted_at is None
+ ),
+ None,
+ )
+
+ if availability is not None:
+ if not availability.is_available:
+ return OperatingWindow(is_available=False)
+
+ return OperatingWindow(
+ is_available=True,
+ opens_at=availability.opens_at,
+ closes_at=availability.closes_at,
+ spans_next_day=availability.spans_next_day,
+ )
+
+ return OperatingWindow(
+ is_available=True,
+ opens_at=venue.open_time,
+ closes_at=venue.close_time,
+ spans_next_day=venue.spans_next_day,
+ )
+
+
+def is_date_blocked(
+ venue,
+ starts_at: datetime,
+ ends_at: datetime,
+) -> bool:
+ starts_at = _to_utc(starts_at)
+ ends_at = _to_utc(ends_at)
+
+ for blocked in getattr(venue, "blocked_dates", []):
+
+ if blocked.deleted_at:
+ continue
+
+ overlap = starts_at < _to_utc(blocked.ends_at) and ends_at > _to_utc(
+ blocked.starts_at,
+ )
+
+ if overlap:
+ return True
+
+ return False
+
+
+def is_slot_blocked(
+ db: Session,
+ venue_id,
+ effective_starts_at: datetime,
+ effective_ends_at: datetime,
+) -> bool:
+ return (
+ db.query(BookingSlot)
+ .filter(
+ BookingSlot.venue_id == venue_id,
+ BookingSlot.is_blocking.is_(True),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_starts_at < effective_ends_at,
+ BookingSlot.effective_ends_at > effective_starts_at,
+ )
+ .first()
+ is not None
+ )
+
+
+def expand_full_day_slot(
+ venue,
+ booking_date: date,
+ db_override: VenueAvailability | None = None,
+) -> tuple[datetime, datetime]:
+ operating_window = resolve_operating_window(
+ venue=venue,
+ booking_date=booking_date,
+ db_override=db_override,
+ )
+
+ if not operating_window.is_available:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Venue closed on selected date",
+ )
+
+ tz = ZoneInfo(venue.timezone)
+
+ starts_at = _localize(
+ booking_date,
+ operating_window.opens_at,
+ tz,
+ )
+
+ ends_at = _localize(
+ booking_date,
+ operating_window.closes_at,
+ tz,
+ )
+
+ if operating_window.spans_next_day:
+ ends_at += timedelta(days=1)
+
+ return (
+ starts_at.astimezone(timezone.utc),
+ ends_at.astimezone(timezone.utc),
+ )
+
+
+def validate_booking_request(
+ db: Session,
+ venue,
+ starts_at: datetime | None,
+ ends_at: datetime | None,
+ booking_type: str,
+ booking_date: date | None = None,
+ guest_count: int | None = None,
+) -> ValidationResponse:
+ venue_tz = ZoneInfo(venue.timezone)
+
+ if booking_type not in venue.allowed_booking_types:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking type not allowed",
+ )
+
+ if booking_type == "full_day":
+ if starts_at is None or ends_at is None:
+ # Original single-day full_day logic
+ if booking_date is None:
+ if starts_at is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="booking_date is required for single-day full day bookings",
+ )
+ booking_date = _to_utc(starts_at).astimezone(venue_tz).date()
+
+ starts_at, ends_at = expand_full_day_slot(
+ venue=venue,
+ booking_date=booking_date,
+ )
+ else:
+ # Multi-day full_day support
+ starts_at = _to_utc(starts_at)
+ ends_at = _to_utc(ends_at)
+ elif starts_at is None or ends_at is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="starts_at and ends_at are required for time slot bookings",
+ )
+ else:
+ starts_at = _to_utc(starts_at)
+ ends_at = _to_utc(ends_at)
+
+ if ends_at <= starts_at:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="End time must be after start time",
+ )
+
+ if starts_at <= datetime.now(timezone.utc):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking must be in the future",
+ )
+
+ if guest_count is not None and guest_count > venue.max_capacity:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Guest count exceeds venue capacity",
+ )
+
+ duration_minutes = int((ends_at - starts_at).total_seconds() / 60)
+
+ if duration_minutes < venue.min_booking_duration_minutes:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking duration too short",
+ )
+
+ # Duration limit - relaxed for full_day multi-day
+ if booking_type == "full_day":
+ max_multi_day_minutes = (
+ getattr(venue, "max_multi_day_duration_days", 7) * 24 * 60
+ )
+ if duration_minutes > max_multi_day_minutes:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Multi-day booking cannot exceed {getattr(venue, 'max_multi_day_duration_days', 7)} days",
+ )
+ elif duration_minutes > venue.max_booking_duration_minutes:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking duration exceeds limit",
+ )
+
+ local_starts_at = starts_at.astimezone(venue_tz)
+ local_ends_at = ends_at.astimezone(venue_tz)
+
+ # Original time_slot interval validation
+ if booking_type == "time_slot" and (
+ _minute_of_day(local_starts_at) % venue.slot_interval_minutes != 0
+ or _minute_of_day(local_ends_at) % venue.slot_interval_minutes != 0
+ or duration_minutes % venue.slot_interval_minutes != 0
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking times must align with slot interval",
+ )
+
+
+ # === Multi-day Operating Window Validation ===
+ current_date = local_starts_at.date()
+ end_date = local_ends_at.date()
+
+ while current_date <= end_date:
+ operating_window = resolve_operating_window(venue, current_date)
+
+ if not operating_window.is_available:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Venue unavailable on {current_date}",
+ )
+
+ window_starts_at = _localize(current_date, operating_window.opens_at, venue_tz)
+ window_ends_at = _localize(current_date, operating_window.closes_at, venue_tz)
+
+ if operating_window.spans_next_day:
+ window_ends_at += timedelta(days=1)
+
+ if current_date == local_starts_at.date():
+ if local_starts_at < window_starts_at:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Booking start must be within operating window",
+ )
+ elif current_date == local_ends_at.date():
+ # Relaxed: Accept whatever end time the frontend sends on the last day
+ # (No strict check on end time for last day)
+ pass
+ # Middle days: only require availability
+
+ current_date += timedelta(days=1)
+
+ # Original effective range + conflict checks
+ effective_starts_at, effective_ends_at = compute_effective_range(
+ starts_at,
+ ends_at,
+ venue.pre_buffer_minutes,
+ venue.post_buffer_minutes,
+ )
+
+ if is_date_blocked(venue, starts_at, ends_at):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Venue blocked for selected period",
+ )
+
+ conflict_exists = is_slot_blocked(
+ db=db,
+ venue_id=venue.id,
+ effective_starts_at=starts_at,
+ effective_ends_at=ends_at,
+ )
+
+ if conflict_exists:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Selected slot unavailable",
+ )
+
+ return ValidationResponse(
+ valid=True,
+ effective_starts_at=effective_starts_at,
+ effective_ends_at=effective_ends_at,
+ )
+
+
+def get_availability_for_date(
+ db: Session,
+ venue_id,
+ booking_date: date,
+ booking_type: str = "time_slot",
+) -> AvailabilityResponse:
+ venue = _get_active_venue_or_404(db, venue_id)
+
+ operating_window = resolve_operating_window(venue, booking_date)
+
+ if not operating_window.is_available:
+ return AvailabilityResponse(
+ date=booking_date,
+ operating_window=operating_window,
+ blocked_slots=[],
+ )
+
+ window_start, window_end = expand_full_day_slot(venue, booking_date)
+
+ # Get blocking slots for the day
+ blocked_slots_db = (
+ db.query(BookingSlot)
+ .filter(
+ BookingSlot.venue_id == venue.id,
+ BookingSlot.is_blocking.is_(True),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_starts_at < window_end,
+ BookingSlot.effective_ends_at > window_start,
+ )
+ .all()
+ )
+
+ if booking_type == "full_day":
+ # Full day is blocked if there's ANY existing booking
+ if blocked_slots_db:
+ return AvailabilityResponse(
+ date=booking_date,
+ operating_window=operating_window,
+ blocked_slots=[
+ BlockedRange(starts_at=window_start, ends_at=window_end)
+ ],
+ )
+ else:
+ return AvailabilityResponse(
+ date=booking_date,
+ operating_window=operating_window,
+ blocked_slots=[],
+ )
+
+ # TIME SLOT: Allow multiple slots per day (partial availability)
+ return AvailabilityResponse(
+ date=booking_date,
+ operating_window=operating_window,
+ blocked_slots=[
+ BlockedRange(starts_at=slot.effective_starts_at, ends_at=slot.effective_ends_at)
+ for slot in blocked_slots_db
+ ],
+ )
+
+
+def _build_calendar_for_venue(
+ db: Session,
+ venue,
+ start_date: date,
+ end_date: date,
+ booking_type: str = "time_slot",
+ include_owner_details: bool = False,
+) -> CalendarResponse:
+ if end_date < start_date:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="end_date must be on or after start_date",
+ )
+
+ if (end_date - start_date).days > 370:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Calendar range cannot exceed 370 days",
+ )
+
+ # Load data for the entire range
+ range_start, _ = _local_day_bounds(venue, start_date)
+ _, range_end = _local_day_bounds(venue, end_date)
+ range_start -= timedelta(minutes=venue.pre_buffer_minutes)
+ range_end += timedelta(days=1, minutes=venue.post_buffer_minutes)
+
+ blocking_slots = (
+ db.query(BookingSlot)
+ .filter(
+ BookingSlot.venue_id == venue.id,
+ BookingSlot.is_blocking.is_(True),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_starts_at < range_end,
+ BookingSlot.effective_ends_at > range_start,
+ )
+ .all()
+ )
+
+ owner_bookings = []
+ if include_owner_details:
+ owner_bookings = (
+ db.query(Booking)
+ .options(joinedload(Booking.slot))
+ .join(BookingSlot, BookingSlot.booking_id == Booking.id)
+ .filter(
+ Booking.venue_id == venue.id,
+ Booking.deleted_at.is_(None),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_starts_at < range_end,
+ BookingSlot.effective_ends_at > range_start,
+ )
+ .order_by(BookingSlot.starts_at.asc())
+ .all()
+ )
+
+ venue_blocks = (
+ db.query(VenueBlockedDate)
+ .filter(
+ VenueBlockedDate.venue_id == venue.id,
+ VenueBlockedDate.deleted_at.is_(None),
+ VenueBlockedDate.starts_at < range_end,
+ VenueBlockedDate.ends_at > range_start,
+ )
+ .all()
+ )
+
+ now = datetime.now(timezone.utc)
+ days: list[CalendarDay] = []
+
+ for calendar_date in _date_range(start_date, end_date):
+ operating_window = resolve_operating_window(venue, calendar_date)
+
+ if operating_window.is_available:
+ window_start, window_end = expand_full_day_slot(venue, calendar_date)
+ else:
+ window_start, window_end = _local_day_bounds(venue, calendar_date)
+
+ effective_window_start, effective_window_end = compute_effective_range(
+ window_start,
+ window_end,
+ venue.pre_buffer_minutes,
+ venue.post_buffer_minutes,
+ )
+
+ # Day-specific blocks
+ day_venue_blocks = [
+ block
+ for block in venue_blocks
+ if _has_overlap(
+ block.starts_at,
+ block.ends_at,
+ effective_window_start,
+ effective_window_end,
+ )
+ ]
+
+ day_blocking_slots = [
+ slot
+ for slot in blocking_slots
+ if _has_overlap(
+ slot.effective_starts_at,
+ slot.effective_ends_at,
+ effective_window_start,
+ effective_window_end,
+ )
+ ]
+
+ blocked_ranges = [
+ CalendarBlockedRange(
+ starts_at=block.starts_at,
+ ends_at=block.ends_at,
+ source="venue_block",
+ reason=block.reason,
+ )
+ for block in day_venue_blocks
+ ]
+ blocked_ranges.extend(
+ CalendarBlockedRange(
+ starts_at=slot.starts_at,
+ ends_at=slot.ends_at,
+ source="booking",
+ )
+ for slot in day_blocking_slots
+ )
+ blocked_ranges.sort(key=lambda item: item.starts_at)
+
+ # Owner bookings for this day
+ day_bookings = []
+ if include_owner_details:
+ day_bookings = [
+ CalendarBookingSummary(
+ id=booking.id,
+ booking_type=booking.booking_type.value,
+ status=booking.status.value,
+ starts_at=booking.slot.starts_at,
+ ends_at=booking.slot.ends_at,
+ effective_starts_at=booking.slot.effective_starts_at,
+ effective_ends_at=booking.slot.effective_ends_at,
+ is_blocking=booking.slot.is_blocking,
+ guest_count=booking.guest_count,
+ event_type=booking.event_type,
+ user_id=booking.user_id,
+ )
+ for booking in owner_bookings
+ if booking.slot
+ and _has_overlap(
+ booking.slot.effective_starts_at,
+ booking.slot.effective_ends_at,
+ effective_window_start,
+ effective_window_end,
+ )
+ ]
+
+ has_conflict = bool(day_venue_blocks or day_blocking_slots)
+ is_future_window = window_end > now
+ supports_full_day = "full_day" in venue.allowed_booking_types
+
+ # Simplified status logic
+ if not operating_window.is_available:
+ day_status = "closed"
+ elif _covers_range(
+ [(b.starts_at, b.ends_at) for b in day_venue_blocks],
+ window_start,
+ window_end,
+ ):
+ day_status = "blocked"
+ elif has_conflict and booking_type == "full_day":
+ day_status = "fully_booked"
+ elif has_conflict:
+ day_status = "partially_booked"
+ else:
+ day_status = "available"
+
+ # Bookable logic
+ is_bookable = False
+ if is_future_window and operating_window.is_available:
+ if booking_type == "full_day":
+ is_bookable = not has_conflict and supports_full_day
+ else: # time_slot
+ is_bookable = not _covers_range(
+ [(s.starts_at, s.ends_at) for s in day_blocking_slots],
+ window_start,
+ window_end,
+ )
+
+ days.append(
+ CalendarDay(
+ date=calendar_date,
+ operating_window=operating_window,
+ status=day_status,
+ is_bookable=is_bookable,
+ available_for_full_day=is_bookable and booking_type == "full_day",
+ blocked_ranges=blocked_ranges,
+ bookings=day_bookings,
+ )
+ )
+
+ return CalendarResponse(
+ venue_id=venue.id,
+ timezone=venue.timezone,
+ start_date=start_date,
+ end_date=end_date,
+ days=days,
+ )
+
+
+def get_calendar(
+ db: Session,
+ venue_id: UUID,
+ start_date: date,
+ end_date: date,
+ booking_type: str = "time_slot",
+) -> CalendarResponse:
+ venue = _get_active_venue_or_404(
+ db,
+ venue_id,
+ )
+
+ return _build_calendar_for_venue(
+ db=db,
+ venue=venue,
+ start_date=start_date,
+ end_date=end_date,
+ booking_type=booking_type,
+ )
+
+
+def get_owner_calendar(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ start_date: date,
+ end_date: date,
+ allow_admin: bool = False,
+) -> CalendarResponse:
+ venue = _get_venue_or_404(
+ db,
+ venue_id,
+ )
+ if not allow_admin:
+ _assert_owner(venue, owner_id)
+
+ return _build_calendar_for_venue(
+ db=db,
+ venue=venue,
+ start_date=start_date,
+ end_date=end_date,
+ include_owner_details=True,
+ )
+
+
+def validate_slot(
+ db: Session,
+ venue_id,
+ booking_type,
+ starts_at,
+ ends_at,
+ booking_date: date | None = None,
+ guest_count: int | None = None,
+):
+ venue = _get_active_venue_or_404(
+ db,
+ venue_id,
+ )
+
+ return validate_booking_request(
+ db=db,
+ venue=venue,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_type=booking_type,
+ booking_date=booking_date,
+ guest_count=guest_count,
+ )
+
+
+def get_pricing_quote(
+ db: Session,
+ venue_id: UUID,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: str,
+):
+ _get_active_venue_or_404(db, venue_id)
+ return get_pricing_quote_for_slot(
+ db=db,
+ venue_id=venue_id,
+ starts_at=_to_utc(starts_at),
+ ends_at=_to_utc(ends_at),
+ booking_type=booking_type,
+ )
diff --git a/apps/api/app/modules/booking/__init__.py b/apps/api/app/modules/booking/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/booking/cancellation.py b/apps/api/app/modules/booking/cancellation.py
new file mode 100644
index 000000000..43f836cbd
--- /dev/null
+++ b/apps/api/app/modules/booking/cancellation.py
@@ -0,0 +1,303 @@
+import math
+from dataclasses import dataclass
+from datetime import datetime
+from uuid import UUID
+
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session
+
+from app.modules.payment import service as payment_service
+from app.modules.notification import service as notifications
+from app.modules.notification.types import NotificationType
+from app.modules.booking.helpers import (
+ _now,
+ _format_inr,
+ _history,
+ _booking_or_404,
+ _assert_booking_user,
+ _assert_booking_owner,
+ _slot_for_update,
+ _booking_out,
+ _load_policy,
+ TERMINAL_STATUSES,
+)
+from app.modules.booking.models import (
+ Booking,
+ BookingStatus,
+ PaymentStatus,
+)
+from app.modules.booking.schemas import (
+ CancellationDisplay,
+ CancellationPreviewOut,
+ BookingOut,
+)
+from app.modules.venue.models import VenueCancellationPolicy
+
+
+@dataclass
+class RefundComputation:
+ refund_amount_paise: int
+ penalty_amount_paise: int
+ refund_pct_applied: float
+ tier_matched: str | None
+
+
+def _compute_refund(
+ booking: Booking,
+ policy: VenueCancellationPolicy | None,
+ cancelled_at: datetime | None = None,
+) -> RefundComputation:
+ cancelled_at = cancelled_at or _now()
+ starts_at = booking.slot.starts_at
+ paid_amount = booking.amount_paid_paise
+
+ if paid_amount <= 0:
+ return RefundComputation(0, 0, 0.0, None)
+
+ hours_notice = (starts_at - cancelled_at).total_seconds() / 3600
+ refund_pct = 0.0
+ tier = "no_show"
+
+ if policy:
+ tiers = [
+ ("tier_1", policy.tier_1_hours, policy.tier_1_refund_pct),
+ ("tier_2", policy.tier_2_hours, policy.tier_2_refund_pct),
+ ("tier_3", policy.tier_3_hours, policy.tier_3_refund_pct),
+ ]
+ for tier_name, hours, pct in tiers:
+ if hours is not None and pct is not None and hours_notice >= hours:
+ tier = tier_name
+ refund_pct = float(pct)
+ break
+ else:
+ refund_pct = float(policy.no_show_refund_pct)
+ else:
+ tier = None
+
+ if policy and not policy.platform_fee_refundable:
+ # Calculate refund on the portion excluding platform fee
+ base_refundable = max(0, paid_amount - booking.platform_fee_paise)
+ refund_amount = round(base_refundable * refund_pct / 100)
+ else:
+ refund_amount = round(paid_amount * refund_pct / 100)
+
+ penalty_amount = max(0, paid_amount - refund_amount)
+ return RefundComputation(
+ refund_amount_paise=refund_amount,
+ penalty_amount_paise=penalty_amount,
+ refund_pct_applied=refund_pct,
+ tier_matched=tier,
+ )
+
+
+def get_cancellation_preview(
+ db: Session,
+ booking_id: UUID,
+ user_id: UUID,
+) -> CancellationPreviewOut:
+ booking = _booking_or_404(db, booking_id)
+ _assert_booking_user(booking, user_id)
+
+ if booking.status not in (
+ BookingStatus.requested,
+ BookingStatus.owner_accepted,
+ BookingStatus.confirmed,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Booking cannot be cancelled at this stage",
+ )
+
+ if (
+ booking.status == BookingStatus.requested
+ or booking.status == BookingStatus.owner_accepted
+ ):
+ # No refund for pre-payment
+ return CancellationPreviewOut(
+ refund_amount_paise=0,
+ penalty_amount_paise=0,
+ refund_pct_applied=0.0,
+ tier_matched=None,
+ display=CancellationDisplay(refund_amount="₹0", penalty_amount="₹0"),
+ )
+
+ refund = _compute_refund(booking, _load_policy(db, booking.venue_id))
+ return CancellationPreviewOut(
+ refund_amount_paise=refund.refund_amount_paise,
+ penalty_amount_paise=refund.penalty_amount_paise,
+ refund_pct_applied=refund.refund_pct_applied,
+ tier_matched=refund.tier_matched,
+ display=CancellationDisplay(
+ refund_amount=_format_inr(refund.refund_amount_paise),
+ penalty_amount=_format_inr(refund.penalty_amount_paise),
+ ),
+ )
+
+
+def user_cancel_booking(db: Session, booking_id: UUID, user_id: UUID) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_user(booking, user_id)
+
+ if booking.status in TERMINAL_STATUSES:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Cannot cancel a terminal booking",
+ )
+
+ # Per spec: allowed on requested, owner_accepted, confirmed
+ if booking.status not in (
+ BookingStatus.requested,
+ BookingStatus.owner_accepted,
+ BookingStatus.confirmed,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Booking cannot be cancelled at this stage",
+ )
+
+ old_status = booking.status
+ slot = (
+ _slot_for_update(db, booking.id)
+ if hasattr(booking, "slot") and booking.slot
+ else None
+ )
+ if slot:
+ slot.is_blocking = False
+
+ booking.status = BookingStatus.user_cancelled
+ booking.cancelled_at = _now()
+
+ metadata = None
+ if old_status == BookingStatus.requested:
+ # Simple cancel - no payment
+ pass
+ elif old_status == BookingStatus.owner_accepted:
+ # Cancel pending PaymentIntent
+ if booking.stripe_advance_payment_intent_id:
+ payment_service.cancel_payment_intent(booking.stripe_advance_payment_intent_id)
+ else: # confirmed
+ refund = _compute_refund(
+ booking, _load_policy(db, booking.venue_id), booking.cancelled_at
+ )
+ if refund.refund_amount_paise > 0:
+ payment_service.refund_for_cancellation(
+ db, booking, refund.refund_amount_paise, "user_cancellation"
+ )
+ booking.refund_amount_paise = refund.refund_amount_paise
+
+ if refund.refund_amount_paise == 0:
+ pass # keep existing payment_status
+ elif refund.refund_amount_paise >= booking.amount_paid_paise:
+ booking.payment_status = PaymentStatus.refunded
+ else:
+ booking.payment_status = PaymentStatus.partially_refunded
+
+ metadata = {
+ "refund_amount_paise": refund.refund_amount_paise,
+ "penalty_amount_paise": refund.penalty_amount_paise,
+ "refund_pct_applied": refund.refund_pct_applied,
+ "tier_matched": refund.tier_matched,
+ }
+
+ db.add(
+ _history(
+ booking,
+ old_status,
+ BookingStatus.user_cancelled,
+ changed_by=user_id,
+ metadata=metadata,
+ )
+ )
+ db.flush()
+ db.refresh(booking)
+
+ # Notifications (spec: notify owner)
+ notifications.notify(
+ db, booking.venue.owner_id, NotificationType.BOOKING_CANCELED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+
+def owner_cancel_forfeit(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+ if booking.status != BookingStatus.confirmed or booking.balance_overdue_at is None:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue")
+
+ _slot_for_update(db, booking.id).is_blocking = False
+ old_status = booking.status
+ owner_share = math.floor(
+ booking.advance_due_paise * (1 - (float(booking.platform_commission_pct) / 100))
+ )
+ booking.status = BookingStatus.balance_overdue_cancelled
+ booking.cancelled_at = _now()
+ db.add(_history(
+ booking,
+ old_status,
+ BookingStatus.balance_overdue_cancelled,
+ changed_by=owner_id,
+ metadata={"owner_share_paise": owner_share, "refund_amount_paise": 0},
+ ))
+ db.flush()
+ db.refresh(booking)
+ notifications.notify(
+ db, booking.user_id, NotificationType.BOOKING_CANCELED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+
+def owner_cancel_goodwill(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+ if booking.status != BookingStatus.confirmed or booking.balance_overdue_at is None:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue")
+
+ _slot_for_update(db, booking.id).is_blocking = False
+ old_status = booking.status
+ refund_amount = math.floor(
+ booking.advance_due_paise * (float(booking.overdue_advance_refund_pct) / 100)
+ )
+ if refund_amount > 0:
+ payment_service.refund_for_cancellation(db, booking, refund_amount, "owner_goodwill")
+
+ booking.status = BookingStatus.user_cancelled
+ booking.cancelled_at = _now()
+ booking.refund_amount_paise = refund_amount
+ booking.payment_status = PaymentStatus.partially_refunded if refund_amount > 0 else booking.payment_status
+ db.add(_history(
+ booking,
+ old_status,
+ BookingStatus.user_cancelled,
+ changed_by=owner_id,
+ metadata={"refund_amount_paise": refund_amount, "goodwill": True},
+ ))
+ db.flush()
+ db.refresh(booking)
+ notifications.notify(
+ db, booking.user_id, NotificationType.BOOKING_CANCELED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+
+def admin_force_cancel(
+ db: Session,
+ booking_id: UUID,
+ admin_id: UUID,
+ reason: str,
+) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ if booking.status in TERMINAL_STATUSES:
+ return _booking_out(booking)
+
+ old_status = booking.status
+ if booking.slot:
+ booking.slot.is_blocking = False
+ booking.status = BookingStatus.admin_cancelled
+ booking.cancelled_at = _now()
+ db.add(_history(booking, old_status, BookingStatus.admin_cancelled, changed_by=admin_id, reason=reason))
+ db.flush()
+ db.refresh(booking)
+ return _booking_out(booking)
diff --git a/apps/api/app/modules/booking/dependencies.py b/apps/api/app/modules/booking/dependencies.py
new file mode 100644
index 000000000..d3c6ac36d
--- /dev/null
+++ b/apps/api/app/modules/booking/dependencies.py
@@ -0,0 +1,47 @@
+from uuid import UUID
+
+from fastapi import Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.exceptions import ForbiddenError, NotFoundError
+from app.modules.auth.dependencies import AuthContext, require_auth
+from app.modules.booking.models import Booking
+
+
+def get_booking_or_404(
+ booking_id: UUID,
+ db: Session = Depends(get_db),
+) -> Booking:
+ booking = (
+ db.query(Booking)
+ .filter(
+ Booking.id == booking_id,
+ Booking.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not booking:
+ raise NotFoundError("Booking not found")
+
+ return booking
+
+
+def require_booking_user(
+ booking: Booking = Depends(get_booking_or_404),
+ current_user: AuthContext = Depends(require_auth),
+) -> Booking:
+ if booking.user_id != current_user.user_id:
+ raise ForbiddenError("Booking access denied")
+
+ return booking
+
+
+def require_booking_owner(
+ booking: Booking = Depends(get_booking_or_404),
+ current_user: AuthContext = Depends(require_auth),
+) -> Booking:
+ if booking.venue.owner_id != current_user.user_id:
+ raise ForbiddenError("Booking owner access denied")
+
+ return booking
diff --git a/apps/api/app/modules/booking/helpers.py b/apps/api/app/modules/booking/helpers.py
new file mode 100644
index 000000000..659020cb8
--- /dev/null
+++ b/apps/api/app/modules/booking/helpers.py
@@ -0,0 +1,242 @@
+import uuid
+import logging
+from datetime import datetime, timezone, date
+from uuid import UUID
+from fastapi import HTTPException, status
+from sqlalchemy.orm import Session, selectinload
+
+from app.modules.booking.models import (
+ Booking,
+ BookingSlot,
+ BookingStatus,
+ BookingStatusHistory,
+ PaymentStatus,
+)
+from app.modules.booking.schemas import BookingOut, BookingDisplay, PaymentOption, PaymentOptions
+from app.modules.venue.models import Venue, VenueCancellationPolicy
+
+logger = logging.getLogger(__name__)
+
+MAX_DEADLINE_EXTENSIONS = 2
+USER_PAYMENT_HOLD_HOURS = 24
+REQUEST_EXPIRY_DAYS = 7
+INSTANT_BOOKING_PAYMENT_TIMEOUT_MINUTES = 15
+
+TERMINAL_STATUSES = {
+ BookingStatus.completed,
+ BookingStatus.hold_expired,
+ BookingStatus.request_expired,
+ BookingStatus.conflict_cancelled,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.owner_rejected,
+ BookingStatus.balance_overdue_cancelled,
+}
+
+
+def _now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _format_inr(paise: int) -> str:
+ rupees = paise / 100
+ return f"INR {rupees:,.0f}"
+
+
+def _enum_value(value) -> str:
+ return value.value if hasattr(value, "value") else str(value)
+
+
+def _history(
+ booking: Booking,
+ old_status: BookingStatus | None,
+ new_status: BookingStatus,
+ changed_by: UUID | None = None,
+ reason: str | None = None,
+ metadata: dict | None = None,
+) -> BookingStatusHistory:
+ return BookingStatusHistory(
+ id=uuid.uuid4(),
+ booking_id=booking.id,
+ old_status=old_status,
+ new_status=new_status,
+ changed_by=changed_by,
+ reason=reason,
+ change_metadata=metadata,
+ )
+
+
+def _booking_or_404(
+ db: Session,
+ booking_id: UUID,
+ for_update: bool = False,
+) -> Booking:
+ query = db.query(Booking).options(
+ selectinload(Booking.slot),
+ selectinload(Booking.venue).selectinload(Venue.photos),
+ selectinload(Booking.user),
+ ).filter(
+ Booking.id == booking_id,
+ Booking.deleted_at.is_(None),
+ )
+ if for_update:
+ query = query.with_for_update()
+
+ booking = query.first()
+ if not booking:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking not found")
+
+ return booking
+
+
+def _assert_booking_user(booking: Booking, user_id: UUID) -> None:
+ if booking.user_id != user_id:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking access denied")
+
+
+def _assert_booking_owner(booking: Booking, owner_id: UUID) -> None:
+ if booking.venue.owner_id != owner_id:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking owner access denied")
+
+
+def _slot_for_update(db: Session, booking_id: UUID) -> BookingSlot:
+ slot = (
+ db.query(BookingSlot)
+ .filter(
+ BookingSlot.booking_id == booking_id,
+ BookingSlot.deleted_at.is_(None),
+ )
+ .with_for_update()
+ .first()
+ )
+ if not slot:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Booking slot not found")
+
+ return slot
+
+
+def _booking_out(booking: Booking) -> BookingOut:
+ slot = booking.slot
+
+ cover_photo = next(
+ (
+ photo.image_url
+ for photo in booking.venue.photos
+ if photo.is_cover and photo.deleted_at is None
+ ),
+ None,
+ )
+
+ payment_required = (booking.status == BookingStatus.payment_pending)
+
+ payment_options = None
+ if booking.status in (BookingStatus.payment_pending, BookingStatus.owner_accepted):
+ payment_options = PaymentOptions(
+ advance=PaymentOption(
+ label="Advance",
+ amount_paise=booking.advance_due_paise,
+ display_amount=_format_inr(booking.advance_due_paise),
+ ),
+ full=PaymentOption(
+ label="Full",
+ amount_paise=booking.quoted_price_paise,
+ display_amount=_format_inr(booking.quoted_price_paise),
+ ),
+ )
+
+ client_secret = None
+ if booking.status in (BookingStatus.payment_pending, BookingStatus.owner_accepted):
+ from sqlalchemy.orm import object_session
+ session = object_session(booking)
+ if session:
+ from app.modules.payment.models import Payment, PaymentAttemptStatus
+ pending_payment = (
+ session.query(Payment)
+ .filter(
+ Payment.booking_id == booking.id,
+ Payment.status == PaymentAttemptStatus.pending
+ )
+ .order_by(Payment.created_at.desc())
+ .first()
+ )
+ if pending_payment:
+ client_secret = pending_payment.stripe_client_secret
+
+ invoice_url = None
+ if booking.status == BookingStatus.confirmed:
+ from sqlalchemy.orm import object_session
+ session = object_session(booking)
+ if session:
+ from app.modules.booking.models import BookingInvoice
+ invoice = (
+ session.query(BookingInvoice)
+ .filter(BookingInvoice.booking_id == booking.id, BookingInvoice.status == "generated")
+ .first()
+ )
+ if invoice:
+ invoice_url = invoice.pdf_url
+
+ return BookingOut(
+ id=booking.id,
+ venue_id=booking.venue_id,
+ venue_name=booking.venue.name,
+ venue_city=booking.venue.city,
+ venue_cover_photo_url=cover_photo,
+ user_id=booking.user_id,
+ user_full_name=booking.user.full_name if booking.user else None,
+ user_email=booking.user.email if booking.user else None,
+ user_phone=booking.user.phone if booking.user else None,
+ booking_type=_enum_value(booking.booking_type),
+ status=_enum_value(booking.status),
+ payment_status=_enum_value(booking.payment_status),
+ starts_at=slot.starts_at,
+ ends_at=slot.ends_at,
+ effective_starts_at=slot.effective_starts_at,
+ effective_ends_at=slot.effective_ends_at,
+ guest_count=booking.guest_count,
+ event_type=booking.event_type,
+ user_notes=booking.user_notes,
+ owner_notes=booking.owner_notes,
+ quoted_price_paise=booking.quoted_price_paise,
+ platform_commission_pct=float(booking.platform_commission_pct),
+ platform_fee_paise=booking.platform_fee_paise,
+ owner_payout_paise=booking.owner_payout_paise,
+ advance_pct=float(booking.advance_pct),
+ advance_due_paise=booking.advance_due_paise,
+ balance_due_paise=booking.balance_due_paise,
+ balance_due_date=booking.balance_due_date,
+ hold_expires_at=booking.hold_expires_at,
+ confirmed_at=booking.confirmed_at,
+ cancelled_at=booking.cancelled_at,
+ expired_at=booking.expired_at,
+ amount_paid_paise=booking.amount_paid_paise,
+ refund_amount_paise=booking.refund_amount_paise,
+ stripe_advance_payment_intent_id=booking.stripe_advance_payment_intent_id,
+ stripe_balance_payment_intent_id=booking.stripe_balance_payment_intent_id,
+ deadline_extension_count=booking.deadline_extension_count,
+ balance_overdue_at=booking.balance_overdue_at,
+ owner_action_deadline=booking.owner_action_deadline,
+ created_at=booking.created_at,
+ display=BookingDisplay(
+ quoted_price=_format_inr(booking.quoted_price_paise),
+ advance_due=_format_inr(booking.advance_due_paise),
+ balance_due=_format_inr(booking.balance_due_paise),
+ platform_fee=_format_inr(booking.platform_fee_paise),
+ owner_payout=_format_inr(booking.owner_payout_paise),
+ ),
+ payment_required=payment_required,
+ payment_options=payment_options,
+ client_secret=client_secret,
+ payment_expires_at=booking.payment_expires_at,
+ auto_confirmed_at=booking.auto_confirmed_at,
+ confirmed_by=booking.confirmed_by,
+ invoice_url=invoice_url,
+ )
+
+
+def _load_policy(db: Session, venue_id: UUID) -> VenueCancellationPolicy | None:
+ return (
+ db.query(VenueCancellationPolicy)
+ .filter(VenueCancellationPolicy.venue_id == venue_id)
+ .first()
+ )
diff --git a/apps/api/app/modules/booking/invoice.py b/apps/api/app/modules/booking/invoice.py
new file mode 100644
index 000000000..8d405b7c7
--- /dev/null
+++ b/apps/api/app/modules/booking/invoice.py
@@ -0,0 +1,171 @@
+"""Async invoice generation for confirmed bookings — orchestration only.
+
+Kept fully independent from booking/service.py and payment/service.py:
+confirm_payment only calls enqueue() (a cheap row insert), never anything
+below that touches reportlab/Cloudinary/email — that work happens later,
+off the request, in app/jobs/invoice_generator.py. This keeps
+payment.service.confirm_payment's row-locked transaction short (see the
+row locking note in that file / CLAUDE.md's payment safety rules).
+
+Queue shape mirrors app.modules.search.indexer: a durable DB row (so
+nothing is lost if Upstash is unreachable) plus a best-effort Upstash push
+for near-immediate pickup, with the same backoff-on-failure retry policy.
+
+PDF rendering + Cloudinary upload live in invoice_pdf.py (a different
+concern — content generation, no DB/queue awareness). The BookingInvoice
+model lives in booking/models.py, alongside every other booking table.
+"""
+import logging
+import uuid
+from datetime import datetime, timezone
+
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from app.core import job_queue
+from app.core import redis as redis_client
+from app.core.config import settings
+from app.modules.booking.models import BookingInvoice
+
+logger = logging.getLogger(__name__)
+
+MAX_RETRIES = len(job_queue.DEFAULT_BACKOFF_SECONDS)
+
+
+# ─── Enqueue (called synchronously from confirm_payment) ──────────────────
+
+def enqueue(db: Session, booking_id: uuid.UUID) -> None:
+ """Insert a pending invoice job and try to push to Upstash (fire-and-forget).
+
+ Safe to call from inside confirm_payment's locked transaction — this is
+ just a row insert, no PDF/network work happens here. If a job already
+ exists for this booking (unique constraint), that's a no-op.
+ """
+ invoice = BookingInvoice(booking_id=booking_id)
+ db.add(invoice)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ return
+
+ try:
+ if redis_client.is_configured():
+ redis_client.get_redis().lpush(settings.upstash_invoice_queue_key, str(invoice.id))
+ except Exception:
+ # Redis push failure is non-fatal — the scheduler worker will poll the DB.
+ pass
+
+
+# ─── Job processing (called from app/jobs/invoice_generator.py) ───────────
+
+def process(db: Session, invoice_id: uuid.UUID | str) -> None:
+ """Process a single invoice job end-to-end: generate PDF, upload, email."""
+ from sqlalchemy.orm import joinedload
+
+ from app.modules.booking.invoice_pdf import generate_pdf, upload_pdf_to_cloudinary
+ from app.modules.booking.models import Booking
+
+ invoice_id = uuid.UUID(str(invoice_id))
+ invoice = db.get(BookingInvoice, invoice_id)
+ if not invoice or invoice.status not in ("pending", "failed"):
+ return
+
+ # Claim the row before doing any work, same as search/indexer.py's
+ # process_job — otherwise a Redis pop and a DB poll landing on the same
+ # still-"pending" row in the same window could both process it,
+ # generating the PDF and sending the email twice.
+ invoice.status = "processing"
+ db.commit()
+
+ try:
+ booking = (
+ db.query(Booking)
+ .options(joinedload(Booking.venue), joinedload(Booking.user), joinedload(Booking.slot))
+ .filter(Booking.id == invoice.booking_id)
+ .first()
+ )
+ if not booking:
+ raise ValueError(f"Booking {invoice.booking_id} not found")
+
+ pdf_bytes = generate_pdf(booking)
+ pdf_url = upload_pdf_to_cloudinary(pdf_bytes, booking.id)
+
+ invoice.pdf_url = pdf_url
+ invoice.status = "generated"
+ db.commit()
+ logger.info("invoice: generated for booking %s -> %s", booking.id, pdf_url)
+
+ _send_invoice_email(db, booking, pdf_url)
+
+ except Exception as exc:
+ db.rollback()
+ invoice = db.get(BookingInvoice, invoice_id)
+ if invoice:
+ invoice.attempts += 1
+ invoice.error_message = str(exc)
+ invoice.status = "failed"
+ db.commit()
+ logger.error("invoice: job %s failed (%s)", invoice_id, exc)
+
+
+def _send_invoice_email(db: Session, booking, pdf_url: str) -> None:
+ """The single 'booking confirmed' email to the customer — held back by
+ confirm_payment (notify(..., skip_email=True)) specifically so it can be
+ sent once, combined with the invoice, instead of as two separate emails.
+ """
+ customer = booking.user
+ if not customer or not customer.email:
+ return
+
+ from app.core.email import send_email
+ from app.modules.notification.models import InAppNotification
+ from app.modules.notification.templates import render_booking_invoice_email
+
+ venue_name = booking.venue.name if booking.venue else "your venue"
+ subject, html = render_booking_invoice_email(customer.full_name, venue_name, pdf_url)
+ if not send_email(customer.email, subject, html):
+ return
+
+ # Best-effort bookkeeping: stamp sent_at on the in-app notification that
+ # confirm_payment wrote without an email, so it doesn't look un-sent.
+ row = (
+ db.query(InAppNotification)
+ .filter(
+ InAppNotification.booking_id == booking.id,
+ InAppNotification.user_id == booking.user_id,
+ InAppNotification.type == "payment_confirmed",
+ InAppNotification.sent_at.is_(None),
+ )
+ .first()
+ )
+ if row:
+ row.sent_at = datetime.now(timezone.utc)
+ db.commit()
+
+
+def retryable_invoice_ids(db: Session, limit: int = 10) -> list[str]:
+ """Return invoice job IDs eligible for processing: pending or failed-with-backoff-elapsed."""
+ pending = (
+ db.query(BookingInvoice)
+ .filter(BookingInvoice.status == "pending")
+ .order_by(BookingInvoice.created_at.asc())
+ .limit(limit)
+ .all()
+ )
+ results = list(pending)
+
+ if len(results) < limit:
+ failed = (
+ db.query(BookingInvoice)
+ .filter(BookingInvoice.status == "failed", BookingInvoice.attempts < MAX_RETRIES)
+ .order_by(BookingInvoice.created_at.asc())
+ .all()
+ )
+ for inv in failed:
+ if len(results) >= limit:
+ break
+ if job_queue.is_backoff_eligible(inv.created_at, inv.attempts):
+ results.append(inv)
+
+ return [str(i.id) for i in results]
diff --git a/apps/api/app/modules/booking/invoice_pdf.py b/apps/api/app/modules/booking/invoice_pdf.py
new file mode 100644
index 000000000..86001a4c6
--- /dev/null
+++ b/apps/api/app/modules/booking/invoice_pdf.py
@@ -0,0 +1,80 @@
+"""Invoice PDF content generation + storage — no DB/queue awareness.
+
+Pure functions: given a Booking (with venue/user/slot eager-loaded), produce
+PDF bytes and upload them. Called from app.modules.booking.invoice's job
+processing; kept separate because "render a document" and "orchestrate a
+retryable background job" are different concerns.
+"""
+import io
+import uuid
+
+
+def generate_pdf(booking) -> bytes:
+ from reportlab.lib import colors
+ from reportlab.lib.pagesizes import A4
+ from reportlab.lib.styles import getSampleStyleSheet
+ from reportlab.lib.units import mm
+ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
+
+ venue = booking.venue
+ customer = booking.user
+ slot = booking.slot
+
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=24 * mm, bottomMargin=24 * mm)
+ styles = getSampleStyleSheet()
+ story = [
+ Paragraph('Venue404', styles["Title"]),
+ Paragraph(f"Invoice — Booking #{str(booking.id)[:8].upper()}", styles["Heading2"]),
+ Spacer(1, 14),
+ ]
+
+ info_rows = [
+ ["Venue", venue.name if venue else "—"],
+ ["Address", venue.address_line1 if venue else "—"],
+ ["Customer", (customer.full_name if customer and customer.full_name else "—")],
+ ["Event date", slot.starts_at.strftime("%d %b %Y") if slot else "—"],
+ ["Guests", str(booking.guest_count)],
+ ["Payment date", booking.confirmed_at.strftime("%d %b %Y, %I:%M %p") if booking.confirmed_at else "—"],
+ ]
+ info_table = Table(info_rows, colWidths=[110 * mm / 2.6, 110 * mm])
+ info_table.setStyle(TableStyle([
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#71717a")),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ]))
+ story.append(info_table)
+ story.append(Spacer(1, 20))
+
+ amount_rows = [["Description", "Amount (INR)"], ["Quoted price", f"Rs. {booking.quoted_price_paise / 100:,.2f}"]]
+ amount_rows.append(["Amount paid", f"Rs. {booking.amount_paid_paise / 100:,.2f}"])
+ if booking.balance_due_paise:
+ amount_rows.append(["Balance due", f"Rs. {booking.balance_due_paise / 100:,.2f}"])
+
+ amounts_table = Table(amount_rows, colWidths=[130 * mm, 60 * mm])
+ amounts_table.setStyle(TableStyle([
+ ("FONTSIZE", (0, 0), (-1, -1), 10),
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#285A48")),
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
+ ("ALIGN", (1, 0), (1, -1), "RIGHT"),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
+ ("GRID", (0, 1), (-1, -1), 0.5, colors.HexColor("#f4f4f5")),
+ ]))
+ story.append(amounts_table)
+
+ doc.build(story)
+ return buf.getvalue()
+
+
+def upload_pdf_to_cloudinary(pdf_bytes: bytes, booking_id: uuid.UUID) -> str:
+ import cloudinary.uploader
+
+ result = cloudinary.uploader.upload(
+ pdf_bytes,
+ folder="venue404/invoices",
+ public_id=f"invoice_{booking_id}.pdf",
+ resource_type="auto",
+ overwrite=True,
+ )
+ return result["secure_url"]
diff --git a/apps/api/app/modules/booking/models.py b/apps/api/app/modules/booking/models.py
new file mode 100644
index 000000000..78d4fd813
--- /dev/null
+++ b/apps/api/app/modules/booking/models.py
@@ -0,0 +1,485 @@
+import enum
+import uuid
+from datetime import datetime, date
+
+from sqlalchemy import (
+ CheckConstraint,
+ Enum,
+ DateTime,
+ Date,
+ ForeignKey,
+ func,
+ Integer,
+ BigInteger,
+ Numeric,
+ Text,
+ String,
+ Boolean,
+)
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import (
+ Mapped,
+ mapped_column,
+ relationship,
+)
+
+from typing import TYPE_CHECKING
+
+from app.core.database import Base
+
+if TYPE_CHECKING:
+ from app.modules.profile.models import Profile
+ from app.modules.venue.models import Venue
+
+
+class BookingType(str, enum.Enum):
+ full_day = "full_day"
+ time_slot = "time_slot"
+
+
+class BookingStatus(str, enum.Enum):
+ requested = "requested"
+ owner_accepted = "owner_accepted"
+ confirmed = "confirmed"
+ completed = "completed"
+ hold_expired = "hold_expired"
+ request_expired = "request_expired"
+ conflict_cancelled = "conflict_cancelled"
+ user_cancelled = "user_cancelled"
+ admin_cancelled = "admin_cancelled"
+ owner_rejected = "owner_rejected"
+ balance_overdue_cancelled = "balance_overdue_cancelled"
+ payment_pending = "payment_pending"
+
+
+class PaymentStatus(str, enum.Enum):
+ unpaid = "unpaid"
+ advance_paid = "advance_paid"
+ fully_paid = "fully_paid"
+ refunded = "refunded"
+ partially_refunded = "partially_refunded"
+
+class Booking(Base):
+ __tablename__ = "bookings"
+
+ __table_args__ = (
+ CheckConstraint(
+ "guest_count > 0",
+ name="ck_bookings_guest_count",
+ ),
+ CheckConstraint(
+ "deadline_extension_count <= 2",
+ name="ck_bookings_deadline_extensions",
+ ),
+ CheckConstraint(
+ "advance_due_paise + balance_due_paise = quoted_price_paise",
+ name="ck_bookings_price_split",
+ ),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ venue_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("venues.id"),
+ nullable=False,
+ )
+
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id"),
+ nullable=False,
+ )
+
+ booking_type: Mapped[BookingType] = mapped_column(
+ Enum(BookingType, name="booking_type"),
+ nullable=False,
+ )
+
+ event_type: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ guest_count: Mapped[int] = mapped_column(
+ Integer,
+ nullable=False,
+ default=1,
+ )
+
+ user_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ owner_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ status: Mapped[BookingStatus] = mapped_column(
+ Enum(BookingStatus, name="booking_status"),
+ nullable=False,
+ default=BookingStatus.requested,
+ )
+
+ requested_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ server_default=func.now(),
+ )
+
+ owner_responded_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ hold_expires_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ confirmed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ balance_due_date: Mapped[date | None] = mapped_column(
+ Date,
+ nullable=True,
+ )
+
+ balance_overdue_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ owner_action_deadline: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ completed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ cancelled_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ expired_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ deadline_extension_count: Mapped[int] = mapped_column(
+ Integer,
+ nullable=False,
+ default=0,
+ )
+
+ pricing_mode: Mapped[str | None] = mapped_column(
+ String,
+ nullable=True,
+ )
+
+ quoted_price_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ pricing_breakdown: Mapped[list[dict] | None] = mapped_column(
+ JSONB,
+ nullable=True,
+ )
+
+ platform_commission_pct: Mapped[float] = mapped_column(
+ Numeric(5, 2),
+ nullable=False,
+ default=0,
+ )
+
+ platform_fee_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ owner_payout_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ advance_pct: Mapped[float] = mapped_column(
+ Numeric(5, 2),
+ nullable=False,
+ default=0,
+ )
+
+ advance_due_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ balance_due_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ overdue_advance_refund_pct: Mapped[float] = mapped_column(
+ Numeric(5, 2),
+ nullable=False,
+ default=0,
+ )
+
+ payment_status: Mapped[PaymentStatus] = mapped_column(
+ Enum(PaymentStatus, name="payment_status"),
+ nullable=False,
+ default=PaymentStatus.unpaid,
+ )
+
+ amount_paid_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ refund_amount_paise: Mapped[int] = mapped_column(
+ BigInteger,
+ nullable=False,
+ default=0,
+ )
+
+ stripe_advance_payment_intent_id: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ stripe_balance_payment_intent_id: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ payment_expires_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ auto_confirmed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ confirmed_by: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ deleted_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ server_default=func.now(),
+ )
+
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ server_default=func.now(),
+ onupdate=func.now(),
+ )
+
+ slot: Mapped["BookingSlot"] = relationship(
+ back_populates="booking",
+ uselist=False,
+ )
+
+ status_history: Mapped[list["BookingStatusHistory"]] = relationship(
+ back_populates="booking",
+ cascade="all, delete-orphan",
+ )
+
+ venue: Mapped["Venue"] = relationship(
+ back_populates="bookings",
+ )
+
+ user: Mapped["Profile"] = relationship(
+ back_populates="bookings",
+ )
+
+
+class BookingSlot(Base):
+ __tablename__ = "booking_slots"
+
+ __table_args__ = (
+ CheckConstraint(
+ "ends_at > starts_at",
+ name="ck_booking_slots_time_order",
+ ),
+ CheckConstraint(
+ "effective_starts_at <= starts_at",
+ name="ck_booking_slots_effective_start",
+ ),
+ CheckConstraint(
+ "effective_ends_at >= ends_at",
+ name="ck_booking_slots_effective_end",
+ ),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("bookings.id"),
+ nullable=False,
+ )
+
+ venue_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("venues.id"),
+ nullable=False,
+ )
+
+ starts_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ )
+
+ ends_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ )
+
+ effective_starts_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ )
+
+ effective_ends_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ )
+
+ is_blocking: Mapped[bool] = mapped_column(
+ Boolean,
+ nullable=False,
+ default=False,
+ )
+
+ deleted_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ server_default=func.now(),
+ )
+
+ booking: Mapped["Booking"] = relationship(
+ back_populates="slot",
+ )
+
+class BookingStatusHistory(Base):
+ __tablename__ = "booking_status_history"
+
+ __table_args__ = (
+ # Update or expand the CHECK constraint to match spec
+ CheckConstraint(
+ """
+ (old_status IS NULL) OR
+ (old_status = 'requested' AND new_status IN ('owner_accepted', 'owner_rejected', 'user_cancelled', 'conflict_cancelled', 'request_expired')) OR
+ (old_status = 'owner_accepted' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled')) OR
+ (old_status = 'confirmed' AND new_status IN ('completed', 'user_cancelled', 'admin_cancelled', 'balance_overdue_cancelled')) OR
+ (old_status = 'hold_expired' AND new_status = 'owner_accepted') OR
+ (old_status = 'payment_pending' AND new_status IN ('confirmed', 'hold_expired', 'user_cancelled', 'admin_cancelled', 'conflict_cancelled'))
+ """,
+ name="ck_booking_status_history_transition",
+ ),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("bookings.id"),
+ nullable=False,
+ )
+
+ old_status: Mapped[BookingStatus | None] = mapped_column(
+ Enum(BookingStatus, name="booking_status"),
+ nullable=True,
+ )
+
+ new_status: Mapped[BookingStatus] = mapped_column(
+ Enum(BookingStatus, name="booking_status"),
+ nullable=False,
+ )
+
+ changed_by: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id"),
+ nullable=True,
+ )
+
+ reason: Mapped[str | None] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
+ change_metadata: Mapped[dict | None] = mapped_column(
+ "metadata",
+ JSONB,
+ nullable=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ nullable=False,
+ server_default=func.now(),
+ )
+
+ booking: Mapped["Booking"] = relationship(
+ back_populates="status_history",
+ )
+
+ changed_by_user: Mapped["Profile"] = relationship(
+ "Profile",
+ foreign_keys=[changed_by],
+ primaryjoin="BookingStatusHistory.changed_by == Profile.id",
+ )
+
+
+class BookingInvoice(Base):
+ """Async-generated invoice PDF for a confirmed booking — see
+ app.modules.booking.invoice for the generation/queueing logic that
+ populates this table."""
+
+ __tablename__ = "booking_invoices"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False, unique=True,
+ )
+ status: Mapped[str] = mapped_column(Text, nullable=False, default="pending") # pending|generated|failed
+ pdf_url: Mapped[str | None] = mapped_column(Text, nullable=True)
+ attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
+ )
diff --git a/apps/api/app/modules/booking/routes.py b/apps/api/app/modules/booking/routes.py
new file mode 100644
index 000000000..b2b988519
--- /dev/null
+++ b/apps/api/app/modules/booking/routes.py
@@ -0,0 +1,148 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import AuthContext, require_auth, require_owner
+from app.modules.booking import service
+from app.modules.booking.schemas import (
+ BookingOut,
+ BookingRequestIn,
+ CancellationPreviewOut,
+ ExtendDeadlineIn,
+ OwnerRejectIn,
+ UpdateOwnerNotesIn,
+)
+
+router = APIRouter()
+
+
+@router.post("/", response_model=BookingOut, status_code=201)
+def create_booking(
+ body: BookingRequestIn,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.create_booking_request(db, auth.user_id, body)
+
+
+@router.get("/", response_model=list[BookingOut])
+def list_my_bookings(
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.list_user_bookings(db, auth.user_id)
+
+
+@router.get("/owner", response_model=list[BookingOut])
+def list_owner_bookings(
+ tab: str | None = None,
+ venue_id: str | None = None,
+ search: str | None = None,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.list_all_owner_bookings(db, auth.user_id, tab, venue_id, search)
+
+
+@router.get("/venues/{venue_id}/bookings", response_model=list[BookingOut])
+def list_venue_bookings(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.list_venue_bookings(db, venue_id, auth.user_id)
+
+
+@router.get("/venues/{venue_id}/bookings/pending", response_model=list[BookingOut])
+def list_pending_venue_bookings(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.list_venue_bookings(db, venue_id, auth.user_id, pending_only=True)
+
+
+@router.get("/{booking_id}", response_model=BookingOut)
+def get_booking(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.get_booking(db, booking_id, auth.user_id)
+
+
+@router.get("/{booking_id}/cancellation-preview", response_model=CancellationPreviewOut)
+def cancellation_preview(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.get_cancellation_preview(db, booking_id, auth.user_id)
+
+
+@router.post("/{booking_id}/cancel", response_model=BookingOut)
+def user_cancel_booking(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.user_cancel_booking(db, booking_id, auth.user_id)
+
+
+@router.post("/{booking_id}/accept", response_model=BookingOut)
+def owner_accept_booking(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.owner_accept_booking(db, booking_id, auth.user_id)
+
+
+@router.post("/{booking_id}/reject", response_model=BookingOut)
+def owner_reject_booking(
+ booking_id: UUID,
+ body: OwnerRejectIn,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.owner_reject_booking(db, booking_id, auth.user_id, body.reason)
+
+
+@router.post("/{booking_id}/extend-balance-deadline", response_model=BookingOut)
+def owner_extend_deadline(
+ booking_id: UUID,
+ body: ExtendDeadlineIn,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.owner_extend_deadline(db, booking_id, auth.user_id, body)
+
+
+@router.post("/{booking_id}/cancel-forfeit", response_model=BookingOut)
+def owner_cancel_forfeit(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.owner_cancel_forfeit(db, booking_id, auth.user_id)
+
+
+@router.post("/{booking_id}/cancel-goodwill", response_model=BookingOut)
+def owner_cancel_goodwill(
+ booking_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.owner_cancel_goodwill(db, booking_id, auth.user_id)
+
+
+@router.patch("/{booking_id}/owner-notes", response_model=BookingOut)
+def update_owner_notes(
+ booking_id: UUID,
+ body: UpdateOwnerNotesIn,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.update_owner_notes(db, booking_id, auth.user_id, body.notes)
diff --git a/apps/api/app/modules/booking/schemas.py b/apps/api/app/modules/booking/schemas.py
new file mode 100644
index 000000000..40063c779
--- /dev/null
+++ b/apps/api/app/modules/booking/schemas.py
@@ -0,0 +1,133 @@
+from datetime import date, datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field, computed_field
+
+
+BookingTypeValue = Literal["full_day", "time_slot"]
+
+
+class BookingRequestIn(BaseModel):
+ venue_id: UUID
+ venue_name: str
+ venue_cover_image: str | None
+ booking_type: BookingTypeValue
+ starts_at: datetime | None = None
+ ends_at: datetime | None = None
+ booking_date: date | None = None
+ guest_count: int = Field(gt=0)
+ event_type: str | None = None
+ user_notes: str | None = None
+ expected_total_paise: int | None = None
+
+
+class PaymentOption(BaseModel):
+ label: str
+ amount_paise: int
+ display_amount: str
+
+
+class PaymentOptions(BaseModel):
+ advance: PaymentOption
+ full: PaymentOption
+
+
+class BookingDisplay(BaseModel):
+ quoted_price: str
+ advance_due: str
+ balance_due: str
+ platform_fee: str
+ owner_payout: str
+
+
+class BookingOut(BaseModel):
+ id: UUID
+ venue_id: UUID
+ venue_name: str
+ venue_city: str
+ venue_cover_photo_url: str | None = None
+ user_id: UUID
+ user_full_name: str | None = None
+ user_email: str | None = None
+ user_phone: str | None = None
+ booking_type: str
+ status: str
+ payment_status: str
+ starts_at: datetime
+ ends_at: datetime
+ created_at: datetime | None = None
+ effective_starts_at: datetime
+ effective_ends_at: datetime
+ guest_count: int
+ event_type: str | None = None
+ user_notes: str | None = None
+ owner_notes: str | None = None
+ quoted_price_paise: int
+ platform_commission_pct: float
+ platform_fee_paise: int
+ owner_payout_paise: int
+ advance_pct: float
+ advance_due_paise: int
+ balance_due_paise: int
+ balance_due_date: date | None = None
+ hold_expires_at: datetime | None = None
+ confirmed_at: datetime | None = None
+ cancelled_at: datetime | None = None
+ expired_at: datetime | None = None
+ amount_paid_paise: int
+ refund_amount_paise: int
+ stripe_advance_payment_intent_id: str | None = None
+ stripe_balance_payment_intent_id: str | None = None
+ deadline_extension_count: int
+ balance_overdue_at: datetime | None = None
+ owner_action_deadline: datetime | None = None
+ display: BookingDisplay
+ payment_required: bool = False
+ payment_options: PaymentOptions | None = None
+ client_secret: str | None = None
+ payment_expires_at: datetime | None = None
+ auto_confirmed_at: datetime | None = None
+ confirmed_by: str | None = None
+ invoice_url: str | None = None
+
+ @computed_field
+ @property
+ def final_owner_payout_paise(self) -> int:
+ if self.status in ("user_cancelled", "owner_cancelled", "rejected"):
+ net = (self.amount_paid_paise or 0) - (self.refund_amount_paise or 0) - (self.platform_fee_paise or 0)
+ return max(0, net)
+ return self.owner_payout_paise or 0
+
+
+class CancellationDisplay(BaseModel):
+ refund_amount: str
+ penalty_amount: str
+
+
+class CancellationPreviewOut(BaseModel):
+ refund_amount_paise: int
+ penalty_amount_paise: int
+ refund_pct_applied: float
+ tier_matched: str | None
+ display: CancellationDisplay
+
+
+class OwnerAcceptIn(BaseModel):
+ pass
+
+
+class OwnerRejectIn(BaseModel):
+ reason: str = Field(min_length=1)
+
+
+class ExtendDeadlineIn(BaseModel):
+ new_due_date: date
+
+
+class UpdateOwnerNotesIn(BaseModel):
+ notes: str | None
+
+
+BookingResponse = BookingOut
+CreateBookingRequest = BookingRequestIn
diff --git a/apps/api/app/modules/booking/service.py b/apps/api/app/modules/booking/service.py
new file mode 100644
index 000000000..e3d351497
--- /dev/null
+++ b/apps/api/app/modules/booking/service.py
@@ -0,0 +1,410 @@
+import logging
+import uuid
+from datetime import date, timedelta
+from uuid import UUID
+
+from fastapi import HTTPException, status
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session, joinedload, selectinload
+from sqlalchemy import or_, and_, func
+
+from app.modules.availability.service import validate_booking_request
+from app.modules.notification import service as notifications
+from app.modules.notification.types import NotificationType
+from app.modules.booking.helpers import (
+ _now,
+ _history,
+ _booking_or_404,
+ _assert_booking_owner,
+ _slot_for_update,
+ _booking_out,
+ MAX_DEADLINE_EXTENSIONS,
+ USER_PAYMENT_HOLD_HOURS,
+)
+from app.modules.booking.models import (
+ Booking,
+ BookingSlot,
+ BookingStatus,
+ BookingType,
+ PaymentStatus,
+)
+from app.modules.booking.schemas import (
+ BookingOut,
+ BookingRequestIn,
+ ExtendDeadlineIn,
+)
+from app.modules.venue.models import Venue, VenueStatus
+from app.modules.profile.models import Profile
+from app.modules.venue.service import ( get_pricing_quote_for_slot, _get_active_venue_or_404 )
+
+# Re-expose functions from cancellation module
+from app.modules.booking.cancellation import (
+ _compute_refund,
+ get_cancellation_preview,
+ user_cancel_booking,
+ owner_cancel_forfeit,
+ owner_cancel_goodwill,
+ admin_force_cancel,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def create_booking_request(
+ db: Session,
+ user_id: UUID,
+ payload: BookingRequestIn,
+) -> BookingOut:
+ # Acquire exclusive write lock on Venue to serialize slot check and creation
+ venue = _get_active_venue_or_404(
+ db,
+ payload.venue_id,
+ for_update=True,
+ )
+
+ validation = validate_booking_request(
+ db=db,
+ venue=venue,
+ starts_at=payload.starts_at,
+ ends_at=payload.ends_at,
+ booking_type=payload.booking_type,
+ booking_date=payload.booking_date,
+ guest_count=payload.guest_count,
+ )
+ starts_at = payload.starts_at
+ ends_at = payload.ends_at
+
+ quote = get_pricing_quote_for_slot(
+ db=db,
+ venue_id=venue.id,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_type=payload.booking_type,
+ )
+
+ if (
+ payload.expected_total_paise is not None
+ and payload.expected_total_paise != quote.quoted_price_paise
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "error": "PRICE_CHANGED",
+ "message": "Pricing has changed since you last viewed this booking.",
+ "quoted_price_paise": quote.quoted_price_paise,
+ "breakdown": [item.model_dump(mode="json") for item in quote.breakdown],
+ },
+ )
+
+ is_instant = (venue.booking_mode == "INSTANT")
+ initial_status = BookingStatus.payment_pending if is_instant else BookingStatus.requested
+ payment_expires_at = _now() + timedelta(minutes=15) if is_instant else None
+
+ booking = Booking(
+ id=uuid.uuid4(),
+ venue_id=venue.id,
+ user_id=user_id,
+ booking_type=BookingType(payload.booking_type),
+ event_type=payload.event_type,
+ guest_count=payload.guest_count,
+ user_notes=payload.user_notes,
+ status=initial_status,
+ payment_expires_at=payment_expires_at,
+ balance_due_date=starts_at.date() - timedelta(days=venue.balance_due_days_before_event),
+ pricing_mode=quote.pricing_mode,
+ quoted_price_paise=quote.quoted_price_paise,
+ pricing_breakdown=[item.model_dump(mode="json") for item in quote.breakdown] or None,
+ platform_commission_pct=quote.platform_commission_pct,
+ platform_fee_paise=quote.platform_fee_paise,
+ owner_payout_paise=quote.owner_payout_paise,
+ advance_pct=quote.advance_pct,
+ advance_due_paise=quote.advance_due_paise,
+ balance_due_paise=quote.balance_due_paise,
+ overdue_advance_refund_pct=venue.overdue_advance_refund_pct,
+ payment_status=PaymentStatus.unpaid,
+ )
+ db.add(booking)
+ db.flush()
+
+ slot = BookingSlot(
+ id=uuid.uuid4(),
+ booking_id=booking.id,
+ venue_id=venue.id,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ effective_starts_at=validation.effective_starts_at,
+ effective_ends_at=validation.effective_ends_at,
+ is_blocking=is_instant,
+ )
+ db.add(slot)
+ db.add(_history(booking, None, initial_status, changed_by=user_id))
+ db.flush()
+
+ if is_instant:
+ from app.modules.payment.service import create_payment_intent
+ try:
+ create_payment_intent(db, user_id, booking.id, payment_type="advance")
+ except Exception as e:
+ logger.exception("Failed to create initial payment intent for instant booking %s", booking.id)
+ db.rollback()
+ if isinstance(e, HTTPException):
+ raise e
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to initialize payment for instant booking"
+ ) from e
+ else:
+ notifications.notify(
+ db, venue.owner_id, NotificationType.NEW_REQUEST_OWNER,
+ context={"venue_name": venue.name}, booking_id=booking.id,
+ )
+ notifications.notify(
+ db, user_id, NotificationType.REQUEST_RECEIVED,
+ context={"venue_name": venue.name}, booking_id=booking.id,
+ )
+
+ db.refresh(booking)
+ return _booking_out(booking)
+
+
+def get_booking(db: Session, booking_id: UUID, user_id: UUID | None = None) -> BookingOut:
+ booking = _booking_or_404(db, booking_id)
+ if user_id is not None and booking.user_id != user_id and booking.venue.owner_id != user_id:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Booking access denied")
+
+ return _booking_out(booking)
+
+
+def list_user_bookings(db: Session, user_id: UUID) -> list[BookingOut]:
+ bookings = (
+ db.query(Booking)
+ .options(
+ joinedload(Booking.slot),
+ joinedload(Booking.user),
+ joinedload(Booking.venue).selectinload(Venue.photos)
+ )
+ .filter(Booking.user_id == user_id, Booking.deleted_at.is_(None))
+ .order_by(Booking.created_at.desc())
+ .all()
+ )
+ return [_booking_out(booking) for booking in bookings]
+
+
+def list_all_owner_bookings(
+ db: Session,
+ owner_id: UUID,
+ tab: str | None = None,
+ venue_id: str | None = None,
+ search: str | None = None
+) -> list[BookingOut]:
+ query = (
+ db.query(Booking)
+ .options(
+ joinedload(Booking.slot),
+ joinedload(Booking.user),
+ joinedload(Booking.venue).selectinload(Venue.photos)
+ )
+ .join(Venue, Booking.venue_id == Venue.id)
+ .join(Profile, Booking.user_id == Profile.id)
+ .filter(Venue.owner_id == owner_id, Booking.deleted_at.is_(None))
+ )
+
+ if venue_id and venue_id != "all":
+ query = query.filter(Booking.venue_id == venue_id)
+
+ if search:
+ search_term = f"%{search}%"
+ query = query.filter(
+ or_(
+ Venue.name.ilike(search_term),
+ Profile.full_name.ilike(search_term)
+ )
+ )
+
+ if tab and tab != "all":
+ if tab == "requested":
+ query = query.filter(Booking.status == BookingStatus.requested)
+ elif tab == "owner_accepted":
+ query = query.filter(Booking.status == BookingStatus.owner_accepted)
+ elif tab == "confirmed":
+ query = query.filter(Booking.status == BookingStatus.confirmed)
+ elif tab == "completed":
+ query = query.filter(Booking.status == BookingStatus.completed)
+ elif tab == "cancelled":
+ query = query.filter(
+ Booking.status.in_([
+ BookingStatus.conflict_cancelled,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.owner_rejected,
+ BookingStatus.balance_overdue_cancelled,
+ BookingStatus.hold_expired,
+ BookingStatus.request_expired
+ ])
+ )
+ elif tab == "overdue":
+ query = query.filter(
+ or_(
+ and_(
+ Booking.status == BookingStatus.confirmed,
+ Booking.balance_overdue_at != None,
+ Booking.balance_overdue_at < func.now()
+ ),
+ and_(
+ Booking.status == BookingStatus.owner_accepted,
+ Booking.hold_expires_at != None,
+ Booking.hold_expires_at < func.now()
+ )
+ )
+ )
+
+ bookings = query.order_by(Booking.created_at.desc()).all()
+ return [_booking_out(booking) for booking in bookings]
+
+
+def list_venue_bookings(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ pending_only: bool = False,
+) -> list[BookingOut]:
+ from app.modules.venue.service import _get_active_venue_or_404
+ venue = _get_active_venue_or_404(db, venue_id)
+ if venue.owner_id != owner_id:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Venue owner access denied")
+
+ query = (
+ db.query(Booking)
+ .options(
+ joinedload(Booking.slot),
+ joinedload(Booking.user),
+ joinedload(Booking.venue).selectinload(Venue.photos)
+ )
+ .filter(
+ Booking.venue_id == venue_id,
+ Booking.deleted_at.is_(None),
+ )
+ )
+ if pending_only:
+ query = query.filter(Booking.status == BookingStatus.requested)
+
+ return [_booking_out(booking) for booking in query.order_by(Booking.requested_at.asc()).all()]
+
+
+def owner_accept_booking(db: Session, booking_id: UUID, owner_id: UUID) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+
+ # Idempotency: If already accepted, return current state
+ if booking.status == BookingStatus.owner_accepted:
+ db.refresh(booking)
+ return _booking_out(booking)
+
+ if booking.status != BookingStatus.requested:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not pending")
+
+ slot = _slot_for_update(db, booking.id)
+ old_status = booking.status
+ slot.is_blocking = True
+ booking.status = BookingStatus.owner_accepted
+ booking.owner_responded_at = _now()
+ booking.hold_expires_at = booking.owner_responded_at + timedelta(hours=USER_PAYMENT_HOLD_HOURS)
+
+ try:
+ db.flush()
+ except IntegrityError as exc:
+ db.rollback()
+ if "booking_slots_no_overlap" in str(exc.orig):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slot already blocked") from exc
+ raise
+
+ # The real advance PaymentIntent (and stripe_advance_payment_intent_id) is created
+ # by payment.service.create_payment_intent when the customer pays the advance.
+ db.add(_history(booking, old_status, BookingStatus.owner_accepted, changed_by=owner_id))
+ db.flush()
+ db.refresh(booking)
+
+ notifications.notify(
+ db, booking.user_id, NotificationType.REQUEST_ACCEPTED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+
+def owner_reject_booking(
+ db: Session,
+ booking_id: UUID,
+ owner_id: UUID,
+ reason: str,
+) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+ if booking.status != BookingStatus.requested:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not pending")
+
+ old_status = booking.status
+ booking.status = BookingStatus.owner_rejected
+ booking.owner_responded_at = _now()
+ db.add(_history(booking, old_status, BookingStatus.owner_rejected, changed_by=owner_id, reason=reason))
+ db.flush()
+ db.refresh(booking)
+
+ notifications.notify(
+ db, booking.user_id, NotificationType.BOOKING_REJECTED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+
+def owner_extend_deadline(
+ db: Session,
+ booking_id: UUID,
+ owner_id: UUID,
+ body: ExtendDeadlineIn,
+) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+ if (
+ booking.status != BookingStatus.confirmed
+ or booking.payment_status != PaymentStatus.advance_paid
+ or booking.balance_overdue_at is None
+ ):
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Booking is not balance overdue")
+ if booking.deadline_extension_count >= MAX_DEADLINE_EXTENSIONS:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Deadline extension limit reached")
+
+ # Ensure event has not already started
+ if booking.slot.starts_at <= _now():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot extend deadline for a past or ongoing event")
+
+ # Ensure new due date is in the future and before the event date
+ if body.new_due_date <= date.today():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New due date must be in the future")
+ if body.new_due_date >= booking.slot.starts_at.date():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New due date must be before the event date")
+
+ booking.balance_due_date = body.new_due_date
+ booking.deadline_extension_count += 1
+ booking.balance_overdue_at = None
+ booking.owner_action_deadline = None
+ # No status change — skip history insert (DB constraint disallows same-status transitions)
+
+ db.flush()
+ db.refresh(booking)
+ notifications.notify(
+ db, booking.user_id, NotificationType.BALANCE_DEADLINE_EXTENDED,
+ context={"venue_name": booking.venue.name}, booking_id=booking.id,
+ )
+ return _booking_out(booking)
+
+def update_owner_notes(db: Session, booking_id: UUID, owner_id: UUID, notes: str | None) -> BookingOut:
+ booking = _booking_or_404(db, booking_id, for_update=True)
+ _assert_booking_owner(booking, owner_id)
+ booking.owner_notes = notes
+ db.flush()
+ db.refresh(booking)
+ return _booking_out(booking)
+
+
+def create_booking(user_id: UUID, body: BookingRequestIn, db: Session) -> BookingOut:
+ return create_booking_request(db, user_id, body)
diff --git a/apps/api/app/modules/booking/state_machine.py b/apps/api/app/modules/booking/state_machine.py
new file mode 100644
index 000000000..a0ef61d8f
--- /dev/null
+++ b/apps/api/app/modules/booking/state_machine.py
@@ -0,0 +1,43 @@
+from app.modules.booking.models import BookingStatus
+
+
+VALID_TRANSITIONS: dict[BookingStatus, set[BookingStatus]] = {
+ BookingStatus.requested: {
+ BookingStatus.owner_accepted,
+ BookingStatus.owner_rejected,
+ BookingStatus.request_expired,
+ BookingStatus.conflict_cancelled,
+ BookingStatus.admin_cancelled,
+ },
+ BookingStatus.payment_pending: {
+ BookingStatus.confirmed,
+ BookingStatus.hold_expired,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.conflict_cancelled,
+ },
+ BookingStatus.owner_accepted: {
+ BookingStatus.confirmed,
+ BookingStatus.hold_expired,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ },
+ BookingStatus.confirmed: {
+ BookingStatus.completed,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.balance_overdue_cancelled,
+ },
+ BookingStatus.completed: set(),
+ BookingStatus.hold_expired: set(),
+ BookingStatus.request_expired: set(),
+ BookingStatus.conflict_cancelled: set(),
+ BookingStatus.user_cancelled: set(),
+ BookingStatus.admin_cancelled: set(),
+ BookingStatus.owner_rejected: set(),
+ BookingStatus.balance_overdue_cancelled: set(),
+}
+
+
+def can_transition(current: BookingStatus, next_status: BookingStatus) -> bool:
+ return next_status in VALID_TRANSITIONS.get(current, set())
diff --git a/apps/api/app/modules/deep_research/__init__.py b/apps/api/app/modules/deep_research/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/deep_research/external_source.py b/apps/api/app/modules/deep_research/external_source.py
new file mode 100644
index 000000000..67429a691
--- /dev/null
+++ b/apps/api/app/modules/deep_research/external_source.py
@@ -0,0 +1,95 @@
+import logging
+from typing import Any
+import httpx
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+PLACES_TEXT_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
+PLACES_DETAILS_URL = "https://places.googleapis.com/v1/places/{place_id}"
+
+
+TEXT_SEARCH_FIELD_MASK = (
+ "places.id," # Essentials
+ "places.displayName," # Pro — venue name
+ "places.formattedAddress," # Pro — full address
+ "places.location," # Pro — lat/lng for map pin
+ "places.postalAddress," # Pro
+ "places.types," # Pro — category tags
+ "places.photos," # Pro — photo reference (uploaded to Cloudinary once, cached forever)
+ "places.businessStatus," # Pro
+)
+
+DETAILS_FIELD_MASK = (
+ "formattedAddress,"
+ "priceLevel,"
+ "googleMapsUri,"
+ "internationalPhoneNumber,"
+ "websiteUri,"
+ "rating,"
+ "userRatingCount,"
+ "regularOpeningHours,"
+ "editorialSummary"
+)
+
+
+class ExternalSourceError(Exception):
+ pass
+
+
+class GooglePlacesSource:
+ def __init__(self, api_key: str | None = None, timeout: float = 10.0):
+ self.api_key = api_key or settings.google_places_api_key
+ self.timeout = timeout
+
+ async def text_search(
+ self,
+ query: str,
+ latitude: float | None = None,
+ longitude: float | None = None,
+ radius_meters: int = 15000,
+ max_results: int = 5,
+ ) -> list[dict[str, Any]]:
+ payload: dict[str, Any] = {
+ "textQuery": query,
+ "maxResultCount": max_results,
+ }
+
+ if latitude is not None and longitude is not None:
+ payload["locationBias"] = {
+ "circle": {
+ "center": {"latitude": latitude, "longitude": longitude},
+ "radius": radius_meters,
+ }
+ }
+
+ headers = {
+ "Content-Type": "application/json",
+ "X-Goog-Api-Key": self.api_key,
+ "X-Goog-FieldMask": TEXT_SEARCH_FIELD_MASK,
+ }
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ resp = await client.post(PLACES_TEXT_SEARCH_URL, json=payload, headers=headers)
+ resp.raise_for_status()
+ except httpx.HTTPError as e:
+ raise ExternalSourceError(f"text_search failed: {e}") from e
+ return resp.json().get("places", [])
+
+ async def place_details(self, place_id: str) -> dict[str, Any]:
+ headers = {
+ "X-Goog-Api-Key": self.api_key,
+ "X-Goog-FieldMask": DETAILS_FIELD_MASK,
+ }
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ resp = await client.get(
+ PLACES_DETAILS_URL.format(place_id=place_id), headers=headers
+ )
+ resp.raise_for_status()
+ except httpx.HTTPError as e:
+ raise ExternalSourceError(f"place_details failed: {e}") from e
+ return resp.json()
+
+
+external_source = GooglePlacesSource()
diff --git a/apps/api/app/modules/deep_research/models.py b/apps/api/app/modules/deep_research/models.py
new file mode 100644
index 000000000..751598dfa
--- /dev/null
+++ b/apps/api/app/modules/deep_research/models.py
@@ -0,0 +1,141 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, Text, func, BigInteger, Date, Enum, text
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+import enum
+from datetime import date as date_type
+
+from app.core.database import Base
+
+
+class ExternalLeadStatus(str, enum.Enum):
+ DISCOVERED = "discovered"
+ CONTACTED = "contacted"
+ ONBOARDED = "onboarded"
+ REJECTED = "rejected"
+
+
+class LeadReservationStatus(str, enum.Enum):
+ NEW = "new"
+ CONTACTED = "contacted"
+ OWNER_INTERESTED = "owner_interested"
+ OWNER_INVITED = "owner_invited"
+ OWNER_ONBOARDED = "owner_onboarded"
+ VENUE_DRAFT_CREATED = "venue_draft_created"
+ VENUE_PENDING_APPROVAL = "venue_pending_approval"
+ VENUE_APPROVED = "venue_approved"
+ BOOKING_CREATED = "booking_created"
+ CLOSED = "closed"
+ CANCELLED = "cancelled"
+ REJECTED = "rejected"
+
+
+
+class DeepResearchQuery(Base):
+ """Logs each Deep Research prompt. Exists primarily so external discovery
+ (Phase 2, see docs/DEEP-RESEARCH-PRD.md) can attach `external_venue_leads`
+ to `discovered_via_query_id` once the user asks us to search beyond the
+ internal catalog.
+
+ Also the source of truth for the admin observability page — the columns
+ below capture the LLM breakdown and result summary at search time, since
+ those are otherwise only ever logged (not queryable) and the raw match
+ scores are computed fresh per-request and never persisted anywhere else.
+ """
+
+ __tablename__ = "deep_research_queries"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False)
+ query_text: Mapped[str] = mapped_column(Text, nullable=False)
+ city_filter: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # Full QueryUnderstanding.model_dump() — intent/venue_type/capacity/etc.
+ understanding_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ result_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ avg_match_score: Mapped[float | None] = mapped_column(Float, nullable=True)
+ # Top few results as [{id, name, match_source, match_score}, ...]
+ top_results_json: Mapped[list | None] = mapped_column(JSONB, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+
+class ExternalDiscoveryRequest(Base):
+ """
+ Owned by external-discovery, NOT deep_research_queries — holds the
+ device location the frontend supplies only when the user explicitly
+ asks to search externally. Keeping this separate avoids needing a
+ migration on a table someone else owns.
+ """
+ __tablename__ = "external_discovery_requests"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ query_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("deep_research_queries.id"), nullable=False, unique=True
+ )
+ latitude: Mapped[float] = mapped_column(Float, nullable=False)
+ longitude: Mapped[float] = mapped_column(Float, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+
+class ExternalVenueLead(Base):
+ __tablename__ = "external_venue_leads"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ discovered_via_query_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("deep_research_queries.id"), nullable=False
+ )
+ source: Mapped[str] = mapped_column(Text, nullable=False, default="google_places")
+ source_ref: Mapped[str] = mapped_column(Text, nullable=False, index=True)
+ name: Mapped[str] = mapped_column(Text, nullable=False)
+ city: Mapped[str | None] = mapped_column(Text, nullable=True)
+ category_guess: Mapped[str | None] = mapped_column(Text, nullable=True)
+ formatted_address: Mapped[str | None] = mapped_column(Text, nullable=True)
+ cover_photo_url: Mapped[str | None] = mapped_column(Text, nullable=True) # Cloudinary URL, cached forever
+ raw_contact_info: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) # admin-only
+ status: Mapped[ExternalLeadStatus] = mapped_column(
+ Enum(ExternalLeadStatus, name="external_lead_status"), nullable=False, default=ExternalLeadStatus.DISCOVERED
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+
+class LeadReservation(Base):
+ __tablename__ = "lead_reservations"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ lead_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("external_venue_leads.id", ondelete="CASCADE"), nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False)
+ status: Mapped[LeadReservationStatus] = mapped_column(
+ Enum(LeadReservationStatus, name="lead_reservation_status", values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=LeadReservationStatus.NEW
+ )
+ platform_fee_paise: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ event_date: Mapped[date_type | None] = mapped_column(Date, nullable=True)
+ guest_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ phone: Mapped[str | None] = mapped_column(Text, nullable=True)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ # ─── Admin conversion workflow (external reservation → onboarded venue) ──
+ owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=True)
+ venue_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=True)
+ booking_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=True)
+ # Picked by the customer at reservation time (dropdown of real venue_categories) so
+ # the admin's later invite-owner step has a real FK instead of guessing one.
+ category_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("venue_categories.id"), nullable=True)
+ contact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ follow_up_date: Mapped[date_type | None] = mapped_column(Date, nullable=True)
+ contact_method: Mapped[str | None] = mapped_column(Text, nullable=True)
+ owner_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ booking_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ # sync_reservation_status_for_venue() (venue/service.py submit_venue,
+ # admin/service.py approve_venue) looks this up on every venue
+ # submit/approve on the platform, not just ones from this workflow.
+ Index("ix_lead_reservations_venue_id", "venue_id", postgresql_where=text("venue_id IS NOT NULL")),
+ )
+
+ lead: Mapped["ExternalVenueLead"] = relationship("ExternalVenueLead")
+
diff --git a/apps/api/app/modules/deep_research/prompts.py b/apps/api/app/modules/deep_research/prompts.py
new file mode 100644
index 000000000..57cad72a2
--- /dev/null
+++ b/apps/api/app/modules/deep_research/prompts.py
@@ -0,0 +1,22 @@
+"""LLM prompts for the Deep Research module, kept separate from the calling
+service so wording can be iterated on without touching pipeline logic.
+"""
+
+QUERY_UNDERSTANDING_SYSTEM_PROMPT = """You are a query-understanding engine for an Indian venue \
+booking marketplace. Break the user's free-text venue search into structured \
+JSON with exactly these keys:
+- intent: short phrase describing what kind of event/venue they want
+- city: city name, or null
+- venue_type: e.g. "marriage hall", "rooftop", "conference room", or null
+- capacity: integer guest count, or null
+- budget_hint: free text like "under 1 lakh" or "cheap", or null
+- date_hint: free text like "this weekend" or "August", or null
+- required_amenities: array of short normalized tags for amenities/services \
+explicitly requested — e.g. "ac", "food_catering", "parking", "travel_service", \
+"pet_friendly", "decoration", "dj_music", "generator_backup". Only include \
+amenities the query actually asks for; do not invent ones it didn't mention.
+- special_requirements: array of short free-text notes for anything else \
+notable that doesn't fit the other fields (accessibility needs, religious/\
+cultural requirements, timing constraints, etc). Empty array if none.
+
+Respond with ONLY the JSON object, no other text."""
diff --git a/apps/api/app/modules/deep_research/query_cache.py b/apps/api/app/modules/deep_research/query_cache.py
new file mode 100644
index 000000000..d15aea568
--- /dev/null
+++ b/apps/api/app/modules/deep_research/query_cache.py
@@ -0,0 +1,76 @@
+"""Upstash-Redis-backed caching for the two expensive, externally-billed
+calls in the Deep Research pipeline: Groq query understanding and Google
+Places text search. Many users type the same/near-same query, so caching on
+normalized query text avoids re-paying for both on every repeat.
+
+Same fail-open pattern as app.core.rate_limit — if Upstash isn't configured
+or is unreachable, callers just get a cache miss and run the real call.
+"""
+import hashlib
+import json
+import logging
+
+from app.core import redis as redis_client
+from app.modules.deep_research.schemas import QueryUnderstanding
+
+logger = logging.getLogger(__name__)
+
+UNDERSTANDING_TTL_SECONDS = 3600 # 1h — query phrasing varies a lot, keep it short
+EXTERNAL_RESULTS_TTL_SECONDS = 21600 # 6h — Places listings barely change; this is the billed call
+
+
+def _normalize(text: str) -> str:
+ return " ".join(text.strip().lower().split())
+
+
+def _key(prefix: str, text: str) -> str:
+ digest = hashlib.sha256(_normalize(text).encode("utf-8")).hexdigest()[:32]
+ return f"dr:{prefix}:{digest}"
+
+
+def get_understanding(query: str) -> QueryUnderstanding | None:
+ if not redis_client.is_configured():
+ return None
+ try:
+ raw = redis_client.get_redis().get(_key("qu", query))
+ if not raw:
+ return None
+ return QueryUnderstanding(**json.loads(raw))
+ except Exception:
+ logger.warning("query_cache.get_understanding failed for query=%r", query, exc_info=True)
+ return None
+
+
+def set_understanding(query: str, breakdown: QueryUnderstanding) -> None:
+ if not redis_client.is_configured():
+ return
+ try:
+ redis_client.get_redis().set(
+ _key("qu", query), json.dumps(breakdown.model_dump()), ex=UNDERSTANDING_TTL_SECONDS
+ )
+ except Exception:
+ logger.warning("query_cache.set_understanding failed for query=%r", query, exc_info=True)
+
+
+def get_external_results(query_text: str) -> list[dict] | None:
+ if not redis_client.is_configured():
+ return None
+ try:
+ raw = redis_client.get_redis().get(_key("ext", query_text))
+ if not raw:
+ return None
+ return json.loads(raw)
+ except Exception:
+ logger.warning("query_cache.get_external_results failed for query_text=%r", query_text, exc_info=True)
+ return None
+
+
+def set_external_results(query_text: str, results: list[dict]) -> None:
+ if not redis_client.is_configured():
+ return
+ try:
+ redis_client.get_redis().set(
+ _key("ext", query_text), json.dumps(results), ex=EXTERNAL_RESULTS_TTL_SECONDS
+ )
+ except Exception:
+ logger.warning("query_cache.set_external_results failed for query_text=%r", query_text, exc_info=True)
diff --git a/apps/api/app/modules/deep_research/query_enrichment.py b/apps/api/app/modules/deep_research/query_enrichment.py
new file mode 100644
index 000000000..991ced99f
--- /dev/null
+++ b/apps/api/app/modules/deep_research/query_enrichment.py
@@ -0,0 +1,23 @@
+"""Builds the query string actually sent to internal retrieval
+(/search/hybrid), from the LLM's structured breakdown rather than the raw
+user sentence.
+
+Why this exists: search_hybrid's FTS half uses plainto_tsquery, which ANDs
+every lexeme together — a raw sentence like "best club venues in ernakulam"
+requires "best" AND "club" AND "venue" AND "ernakulam" to all appear in a
+venue's indexed text, so filler words alone can sink an otherwise-good match.
+City/date/budget are already passed as separate structured filters (or
+ignored, for hints hybrid search has no filter for), so they're dropped here
+too — this function keeps only the words that are actually useful lexical/
+semantic anchors: intent, venue type, and required amenities.
+"""
+
+from app.modules.deep_research.schemas import QueryUnderstanding
+
+
+def build_internal_search_query(raw_query: str, breakdown: QueryUnderstanding) -> str:
+ terms = [breakdown.intent, breakdown.venue_type, *breakdown.required_amenities]
+ # dict.fromkeys dedupes while preserving first-seen order (e.g. intent and
+ # venue_type are often the same phrase from the LLM).
+ cleaned = " ".join(dict.fromkeys(t.strip() for t in terms if t and t.strip()))
+ return cleaned or raw_query
diff --git a/apps/api/app/modules/deep_research/query_understanding.py b/apps/api/app/modules/deep_research/query_understanding.py
new file mode 100644
index 000000000..757406adb
--- /dev/null
+++ b/apps/api/app/modules/deep_research/query_understanding.py
@@ -0,0 +1,73 @@
+"""Deep Research's query-understanding stage.
+
+First step of the Deep Research pipeline (see docs/deep-research-architecture.md):
+takes the user's free-text prompt and asks Groq to break it into structured
+signals (intent, city, venue type, capacity, budget/date hints, amenities,
+and any other requirements). This is deliberately separate from
+app.modules.search.query_normalizer, which is a cheap fuzzy-typo corrector
+with no LLM call — this stage is the new, LLM-backed layer that sits in
+front of it.
+
+The breakdown returned here is logged, then consumed by
+app.modules.deep_research.service.run_search, which persists the query and
+builds the internal /search/hybrid request from it (see query_enrichment.py).
+"""
+
+import json
+import logging
+
+from app.infrastructure.llm import groq
+from app.modules.deep_research import query_cache
+from app.modules.deep_research.prompts import QUERY_UNDERSTANDING_SYSTEM_PROMPT
+from app.modules.deep_research.schemas import QueryUnderstanding
+
+logger = logging.getLogger(__name__)
+
+
+def understand_query(query: str) -> QueryUnderstanding:
+ """Call Groq to break the raw query into structured signals, log the
+ breakdown, and return it.
+
+ Checks the Upstash cache first (many users type the same/near-same
+ query) — a hit skips the Groq call entirely. See query_cache.py.
+
+ Degrades to a raw-query-only breakdown (rather than raising) on any
+ failure — missing/invalid GROQ_API_KEY, Groq outage, timeout, or an
+ unparseable response — so a Groq problem takes down query understanding
+ only, not the whole /deep-research/search request. Internal retrieval
+ still runs on the raw query in that case; it just misses the structured
+ city/capacity/amenity signals for this one search.
+ """
+ cached = query_cache.get_understanding(query)
+ if cached is not None:
+ logger.info("deep_research.query_understanding: cache hit for query=%r", query)
+ return cached
+
+ try:
+ raw_content = groq.chat_completion(
+ messages=[
+ {"role": "system", "content": QUERY_UNDERSTANDING_SYSTEM_PROMPT},
+ {"role": "user", "content": query},
+ ]
+ )
+ parsed = json.loads(raw_content)
+ breakdown = QueryUnderstanding(**parsed)
+ query_cache.set_understanding(query, breakdown)
+ except (json.JSONDecodeError, TypeError, ValueError):
+ logger.warning(
+ "deep_research.query_understanding: could not parse Groq response as JSON: %r",
+ raw_content,
+ )
+ breakdown = QueryUnderstanding(intent=query)
+ except Exception:
+ logger.exception(
+ "deep_research.query_understanding: Groq call failed for query=%r", query
+ )
+ breakdown = QueryUnderstanding(intent=query)
+
+ logger.info(
+ "deep_research.query_understanding: query=%r breakdown=%s",
+ query,
+ breakdown.model_dump(),
+ )
+ return breakdown
diff --git a/apps/api/app/modules/deep_research/routes.py b/apps/api/app/modules/deep_research/routes.py
new file mode 100644
index 000000000..313d1279e
--- /dev/null
+++ b/apps/api/app/modules/deep_research/routes.py
@@ -0,0 +1,65 @@
+from fastapi import APIRouter, Depends, HTTPException
+from uuid import UUID
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.core.database import get_db
+from app.core.rate_limit import enforce_daily_limit, enforce_per_minute_limit
+from app.modules.auth.dependencies import AuthContext, require_auth
+from app.modules.deep_research import service
+from app.modules.deep_research.schemas import (
+ DeepResearchSearchRequest, DeepResearchSearchResponse,
+ TriggerExternalDiscoveryRequest, ExternalLeadPublic,
+ ReserveLeadRequest, ReserveLeadResponse,
+ UserReservationResponse,
+)
+
+router = APIRouter()
+
+
+@router.post("/search", response_model=DeepResearchSearchResponse)
+def search(
+ body: DeepResearchSearchRequest,
+ db: Session = Depends(get_db),
+ auth: AuthContext = Depends(require_auth),
+):
+ enforce_per_minute_limit(auth.user_id, "deep_research")
+ enforce_daily_limit(auth.user_id, "deep_research", settings.deep_research_daily_limit)
+ return service.run_search(db, auth.user_id, body.query, body.page, body.page_size)
+
+
+@router.post("/external", response_model=list[ExternalLeadPublic])
+async def trigger_external(
+ body: TriggerExternalDiscoveryRequest,
+ db: Session = Depends(get_db),
+ auth: AuthContext = Depends(require_auth),
+):
+ """
+ Async endpoint: awaits Google Places + concurrent Cloudinary uploads,
+ then returns all discovered leads in one response. No jobs, no polling.
+ """
+ enforce_per_minute_limit(auth.user_id, "deep_research_external")
+ return await service.run_external_discovery(
+ db, body.query_id, body.latitude, body.longitude
+ )
+
+
+@router.post("/leads/{lead_id}/reserve", response_model=ReserveLeadResponse)
+def reserve(
+ lead_id: UUID,
+ body: ReserveLeadRequest,
+ db: Session = Depends(get_db),
+ auth: AuthContext = Depends(require_auth),
+):
+ reservation = service.reserve_lead(
+ db, lead_id, auth.user_id, body.category_id, body.event_date, body.guest_count, body.phone, body.notes
+ )
+ return ReserveLeadResponse(reservation_id=reservation.id, status=reservation.status.value)
+
+
+@router.get("/reservations", response_model=list[UserReservationResponse])
+def get_reservations(
+ db: Session = Depends(get_db),
+ auth: AuthContext = Depends(require_auth),
+):
+ return service.get_user_reservations(db, auth.user_id)
diff --git a/apps/api/app/modules/deep_research/schemas.py b/apps/api/app/modules/deep_research/schemas.py
new file mode 100644
index 000000000..3e75dce07
--- /dev/null
+++ b/apps/api/app/modules/deep_research/schemas.py
@@ -0,0 +1,75 @@
+from typing import Optional
+from uuid import UUID
+from datetime import datetime
+
+from pydantic import BaseModel
+
+from app.modules.search.schemas import SearchResult
+from app.shared.pagination import Page
+
+
+class QueryUnderstanding(BaseModel):
+ intent: str
+ city: Optional[str] = None
+ venue_type: Optional[str] = None
+ capacity: Optional[int] = None
+ budget_hint: Optional[str] = None
+ date_hint: Optional[str] = None
+ required_amenities: list[str] = []
+ special_requirements: list[str] = []
+
+
+class DeepResearchSearchRequest(BaseModel):
+ query: str
+ page: int = 1
+ page_size: int = 20
+
+
+class DeepResearchSearchResponse(BaseModel):
+ query_id: UUID
+ understanding: QueryUnderstanding
+ internal_results: Page[SearchResult]
+
+
+class TriggerExternalDiscoveryRequest(BaseModel):
+ query_id: UUID
+ latitude: float
+ longitude: float
+
+
+class ExternalLeadPublic(BaseModel):
+ id: UUID
+ name: str
+ city: Optional[str] = None
+ formatted_address: Optional[str] = None
+ cover_photo_url: Optional[str] = None
+ category_guess: Optional[str] = None
+ source: str
+ disclaimer: str = (
+ "Not verified on Venue404. Reservation is a request only, subject to "
+ "availability, and includes an additional platform fee."
+ )
+
+
+class ReserveLeadRequest(BaseModel):
+ category_id: UUID
+ event_date: Optional[str] = None
+ guest_count: Optional[int] = None
+ phone: Optional[str] = None
+ notes: Optional[str] = None
+
+
+class ReserveLeadResponse(BaseModel):
+ reservation_id: UUID
+ status: str
+
+
+class UserReservationResponse(BaseModel):
+ id: UUID
+ lead: ExternalLeadPublic
+ status: str
+ event_date: Optional[str] = None
+ guest_count: Optional[int] = None
+ phone: Optional[str] = None
+ notes: Optional[str] = None
+ created_at: datetime
diff --git a/apps/api/app/modules/deep_research/service.py b/apps/api/app/modules/deep_research/service.py
new file mode 100644
index 000000000..27b2bae8e
--- /dev/null
+++ b/apps/api/app/modules/deep_research/service.py
@@ -0,0 +1,701 @@
+"""Deep Research orchestration.
+
+Phase 1: Query Understanding → Internal Retrieval (hybrid search) → persisted
+query log (breakdown + result summary for admin observability).
+
+Phase 2 (this file): External discovery via Google Places. Runs as a single
+async request — no job queue, no polling. Cloudinary photo uploads are
+concurrent (asyncio.gather), so total wait time ≈ 1× upload rather than N×.
+"""
+
+import asyncio
+import logging
+import math
+from uuid import UUID
+
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.modules.deep_research.models import (
+ DeepResearchQuery,
+ ExternalDiscoveryRequest,
+ ExternalVenueLead,
+ LeadReservation,
+ LeadReservationStatus,
+)
+from app.modules.deep_research.query_enrichment import build_internal_search_query
+from app.modules.deep_research.query_understanding import understand_query
+from app.modules.deep_research.schemas import DeepResearchSearchResponse, ExternalLeadPublic
+from app.modules.search import service as search_service
+from app.modules.search.schemas import SearchParams
+
+logger = logging.getLogger(__name__)
+
+_TOP_RESULTS_TRACKED = 5
+MAX_EXTERNAL_RESULTS = 5
+DEFAULT_RADIUS_METERS = 15000
+
+
+# ─── Phase 1: Internal search ────────────────────────────────────────────────
+
+def run_search(
+ db: Session, user_id: UUID, query: str, page: int, page_size: int
+) -> DeepResearchSearchResponse:
+ breakdown = understand_query(query)
+
+ search_params = SearchParams(
+ q=build_internal_search_query(query, breakdown),
+ city=breakdown.city or "",
+ capacity=breakdown.capacity or 0,
+ page=page,
+ page_size=page_size,
+ )
+ internal_results = search_service.search_hybrid(db, search_params)
+
+ match_scores = [r.match_score for r in internal_results.items if r.match_score is not None]
+ avg_match_score = sum(match_scores) / len(match_scores) if match_scores else None
+ top_results = [
+ {
+ "id": str(r.id),
+ "name": r.name,
+ "match_source": r.match_source,
+ "match_score": r.match_score,
+ }
+ for r in internal_results.items[:_TOP_RESULTS_TRACKED]
+ ]
+
+ research_query = DeepResearchQuery(
+ user_id=user_id,
+ query_text=query,
+ city_filter=breakdown.city,
+ understanding_json=breakdown.model_dump(),
+ result_count=internal_results.total,
+ avg_match_score=avg_match_score,
+ top_results_json=top_results,
+ )
+ db.add(research_query)
+ db.commit()
+ db.refresh(research_query)
+
+ return DeepResearchSearchResponse(
+ query_id=research_query.id,
+ understanding=breakdown,
+ internal_results=internal_results,
+ )
+
+
+# ─── Phase 2: External discovery ─────────────────────────────────────────────
+
+async def _upload_photo_to_cloudinary(
+ source_ref: str, photos: list[dict]
+) -> str | None:
+ """
+ Upload the first Google Places photo to Cloudinary.
+ Returns the secure Cloudinary URL, or None if upload fails.
+ Non-fatal — a lead without a photo is still a valid lead.
+ """
+ import cloudinary
+ import cloudinary.uploader
+ from app.core.config import settings
+
+ if not photos:
+ return None
+
+ photo_name = photos[0].get("name")
+ if not photo_name:
+ return None
+
+ google_photo_url = (
+ f"https://places.googleapis.com/v1/{photo_name}/media"
+ f"?key={settings.google_places_api_key}"
+ f"&maxWidthPx=800&maxHeightPx=500"
+ )
+ try:
+ # Run the blocking Cloudinary SDK call in a thread so it doesn't
+ # block the event loop while we wait for the upload.
+ loop = asyncio.get_event_loop()
+ result = await loop.run_in_executor(
+ None,
+ lambda: cloudinary.uploader.upload(
+ google_photo_url,
+ folder="venue404/external_leads",
+ public_id=f"gplace_{source_ref}",
+ overwrite=False,
+ resource_type="image",
+ ),
+ )
+ url = result.get("secure_url")
+ logger.info("Uploaded photo for place %s → %s", source_ref, url)
+ return url
+ except Exception as exc:
+ logger.warning("Photo upload failed for %s: %s", source_ref, exc)
+ return None
+
+
+async def run_external_discovery(
+ db: Session, query_id: UUID, latitude: float, longitude: float
+) -> list[ExternalLeadPublic]:
+ """
+ Async function that:
+ 1. Saves an audit row for the location supplied by the browser.
+ 2. Calls Google Places (async httpx).
+ 3. Deduplicates against our internal Venue table.
+ 4. Uploads all photos concurrently via asyncio.gather.
+ 5. Bulk-inserts ExternalVenueLead rows.
+ 6. Returns the list of ExternalLeadPublic immediately.
+ """
+ import cloudinary
+ from app.core.config import settings
+ from app.modules.deep_research.external_source import external_source
+ from app.modules.venue.models import Venue
+ from sqlalchemy import func
+
+ # ── 1. Audit row ──────────────────────────────────────────────────────────
+ ctx = ExternalDiscoveryRequest(
+ query_id=query_id, latitude=latitude, longitude=longitude
+ )
+ db.add(ctx)
+ db.commit()
+
+ # ── 2. Load query context ─────────────────────────────────────────────────
+ query_row = db.get(DeepResearchQuery, query_id)
+ if query_row is None:
+ raise ValueError(f"deep_research_queries row {query_id} not found")
+
+ from app.modules.deep_research.schemas import QueryUnderstanding
+
+ breakdown = (
+ QueryUnderstanding(**query_row.understanding_json)
+ if query_row.understanding_json
+ else QueryUnderstanding(intent="", city=query_row.city_filter)
+ )
+ # For external discovery, we must include the city in the text query
+ # since Google Places uses it alongside the location bias.
+ query_text = build_internal_search_query(query_row.query_text, breakdown)
+ if breakdown.city and breakdown.city.lower() not in query_text.lower():
+ query_text = f"{query_text} in {breakdown.city}"
+
+ # If the user explicitly provided a city, rely entirely on the text query
+ # (e.g. "wedding hall in Bangalore") rather than skewing results to their GPS location.
+ if breakdown.city:
+ use_lat, use_lng = None, None
+ else:
+ use_lat, use_lng = latitude, longitude
+
+ # ── 3. Google Places call (async), cached on normalized query text ────────
+ # Location bias only changes result ordering, not which cache bucket a
+ # repeat query lands in — acceptable since the query text is what users
+ # actually repeat (Places listings for a query barely move over a few
+ # hours anyway). See query_cache.py for the caching rationale.
+ from app.modules.deep_research import query_cache
+
+ raw_results = query_cache.get_external_results(query_text)
+ if raw_results is not None:
+ logger.info("deep_research.external_discovery: cache hit for query_text=%r", query_text)
+ else:
+ raw_results = await external_source.text_search(
+ query=query_text,
+ latitude=use_lat,
+ longitude=use_lng,
+ radius_meters=DEFAULT_RADIUS_METERS,
+ max_results=10, # fetch extra to allow for dedup filtering
+ )
+ query_cache.set_external_results(query_text, raw_results)
+
+ # ── 4. Deduplicate against internal venues ────────────────────────────────
+ valid_raws: list[dict] = []
+ for raw in raw_results:
+ name = raw.get("displayName", {}).get("text", "")
+ if query_row.city_filter and name:
+ clash = (
+ db.query(Venue)
+ .filter(
+ func.lower(Venue.name) == name.lower(),
+ func.lower(Venue.city) == query_row.city_filter.lower(),
+ )
+ .first()
+ )
+ if clash:
+ continue
+ valid_raws.append(raw)
+ if len(valid_raws) >= MAX_EXTERNAL_RESULTS:
+ break
+
+ if not valid_raws:
+ return []
+
+ # ── 5. Configure Cloudinary once ──────────────────────────────────────────
+ cloudinary.config(
+ cloud_name=settings.cloudinary_cloud_name,
+ api_key=settings.cloudinary_api_key,
+ api_secret=settings.cloudinary_api_secret,
+ secure=True,
+ )
+
+ # ── 6. Upload photos concurrently ─────────────────────────────────────────
+ # For each lead, check if we already have a cached Cloudinary URL first.
+ async def resolve_photo(raw: dict) -> str | None:
+ source_ref = raw["id"]
+ cached = (
+ db.query(ExternalVenueLead)
+ .filter(
+ ExternalVenueLead.source_ref == source_ref,
+ ExternalVenueLead.cover_photo_url.isnot(None),
+ )
+ .first()
+ )
+ if cached:
+ logger.debug("Reusing cached photo for place %s", source_ref)
+ return cached.cover_photo_url
+ return await _upload_photo_to_cloudinary(source_ref, raw.get("photos", []))
+
+ photo_urls: list[str | None] = await asyncio.gather(
+ *[resolve_photo(raw) for raw in valid_raws]
+ )
+
+ # ── 7. Persist leads and return ───────────────────────────────────────────
+ public_leads: list[ExternalLeadPublic] = []
+ for raw, cover_photo_url in zip(valid_raws, photo_urls):
+ lead = ExternalVenueLead(
+ discovered_via_query_id=query_id,
+ source="google_places",
+ source_ref=raw["id"],
+ name=raw.get("displayName", {}).get("text", "Unknown venue"),
+ city=(
+ raw.get("postalAddress", {}).get("locality")
+ or query_row.city_filter
+ ),
+ formatted_address=raw.get("formattedAddress"),
+ cover_photo_url=cover_photo_url,
+ raw_contact_info={},
+ )
+ db.add(lead)
+ db.flush() # get lead.id without a full commit yet
+ public_leads.append(_to_public_lead(lead))
+
+ db.commit()
+ return public_leads
+
+
+# ─── Helpers ──────────────────────────────────────────────────────────────────
+
+def _to_public_lead(lead: ExternalVenueLead) -> ExternalLeadPublic:
+ return ExternalLeadPublic(
+ id=lead.id,
+ name=lead.name,
+ city=lead.city,
+ formatted_address=lead.formatted_address,
+ cover_photo_url=lead.cover_photo_url,
+ category_guess=lead.category_guess,
+ source=lead.source,
+ )
+
+
+# ─── Reservations ─────────────────────────────────────────────────────────────
+
+def reserve_lead(
+ db: Session,
+ lead_id: UUID,
+ user_id: UUID,
+ category_id: UUID,
+ event_date=None,
+ guest_count=None,
+ phone=None,
+ notes=None,
+) -> LeadReservation:
+ import httpx
+ from app.core.config import settings
+ from app.modules.deep_research.external_source import DETAILS_FIELD_MASK, PLACES_DETAILS_URL
+
+ lead = db.get(ExternalVenueLead, lead_id)
+ if lead is None:
+ raise ValueError(f"No lead found for id={lead_id}")
+
+ if not lead.raw_contact_info:
+ # Fetch details synchronously (this is a sync route, one-time fetch per lead)
+ headers = {
+ "X-Goog-Api-Key": settings.google_places_api_key,
+ "X-Goog-FieldMask": DETAILS_FIELD_MASK,
+ }
+ try:
+ resp = httpx.get(
+ PLACES_DETAILS_URL.format(place_id=lead.source_ref),
+ headers=headers,
+ timeout=10.0,
+ )
+ resp.raise_for_status()
+ details = resp.json()
+ except Exception as exc:
+ logger.warning("Could not fetch place details for %s: %s", lead.source_ref, exc)
+ details = {}
+
+ lead.raw_contact_info = {
+ "phone": details.get("internationalPhoneNumber"),
+ "website": details.get("websiteUri"),
+ "formatted_address": details.get("formattedAddress"),
+ "price_level": details.get("priceLevel"),
+ "rating": details.get("rating"),
+ "user_rating_count": details.get("userRatingCount"),
+ "regular_opening_hours": details.get("regularOpeningHours"),
+ "editorial_summary": details.get("editorialSummary"),
+ "google_maps_uri": details.get("googleMapsUri"),
+ }
+
+ reservation = LeadReservation(
+ lead_id=lead.id,
+ user_id=user_id,
+ category_id=category_id,
+ platform_fee_paise=50000,
+ event_date=event_date,
+ guest_count=guest_count,
+ phone=phone,
+ notes=notes,
+ )
+ db.add(reservation)
+ db.commit()
+ db.refresh(reservation)
+ return reservation
+
+
+def get_user_reservations(db: Session, user_id: UUID) -> list[dict]:
+ from sqlalchemy.orm import joinedload
+
+ reservations = (
+ db.query(LeadReservation)
+ .options(joinedload(LeadReservation.lead))
+ .filter(LeadReservation.user_id == user_id)
+ .order_by(LeadReservation.created_at.desc())
+ .all()
+ )
+
+ result = []
+ for res in reservations:
+ result.append(
+ {
+ "id": res.id,
+ "status": res.status.value,
+ "event_date": res.event_date.isoformat() if res.event_date else None,
+ "guest_count": res.guest_count,
+ "phone": res.phone,
+ "notes": res.notes,
+ "created_at": res.created_at,
+ "lead": _to_public_lead(res.lead),
+ }
+ )
+ return result
+
+
+# ─── Admin conversion workflow ────────────────────────────────────────────────
+# Converts an external reservation into an onboarded owner + venue + normal
+# Venue404 booking. See docs/Venue404_External_Reservation_Onboarding_PRD.md
+
+def sync_reservation_status_for_venue(db: Session, venue_id: UUID, new_venue_status) -> None:
+ """Advances the linked LeadReservation's status when its venue is
+ submitted for review or approved. Called from venue/admin service code
+ on the same transaction, before that caller's commit — no commit here.
+ """
+ from app.modules.venue.models import VenueStatus
+
+ mapping = {
+ VenueStatus.pending_approval: LeadReservationStatus.VENUE_PENDING_APPROVAL,
+ VenueStatus.approved: LeadReservationStatus.VENUE_APPROVED,
+ }
+ target = mapping.get(new_venue_status)
+ if target is None:
+ return
+
+ reservation = db.query(LeadReservation).filter(LeadReservation.venue_id == venue_id).first()
+ if reservation and reservation.status != target:
+ reservation.status = target
+
+
+def list_external_reservations(
+ db: Session, status: str | None, page: int, page_size: int
+) -> dict:
+ from sqlalchemy.orm import joinedload
+ from app.modules.profile.models import Profile
+
+ query = (
+ db.query(LeadReservation)
+ .options(joinedload(LeadReservation.lead))
+ .order_by(LeadReservation.created_at.desc())
+ )
+ if status:
+ query = query.filter(LeadReservation.status == LeadReservationStatus(status))
+
+ total = query.count()
+ reservations = query.offset((page - 1) * page_size).limit(page_size).all()
+
+ from app.modules.venue.models import VenueCategory
+
+ customer_ids = {r.user_id for r in reservations}
+ customers = {
+ p.id: p for p in db.query(Profile).filter(Profile.id.in_(customer_ids)).all()
+ } if customer_ids else {}
+
+ category_ids = {r.category_id for r in reservations if r.category_id}
+ categories = {
+ c.id: c for c in db.query(VenueCategory).filter(VenueCategory.id.in_(category_ids)).all()
+ } if category_ids else {}
+
+ items = []
+ for r in reservations:
+ customer = customers.get(r.user_id)
+ category = categories.get(r.category_id) if r.category_id else None
+ contact_info = r.lead.raw_contact_info or {}
+ items.append({
+ "id": r.id,
+ "status": r.status.value,
+ "lead_name": r.lead.name,
+ "lead_city": r.lead.city,
+ "lead_formatted_address": r.lead.formatted_address,
+ "lead_category_guess": r.lead.category_guess,
+ "lead_cover_photo_url": r.lead.cover_photo_url,
+ "lead_phone": contact_info.get("phone"),
+ "lead_website": contact_info.get("website"),
+ "lead_rating": contact_info.get("rating"),
+ "lead_google_maps_uri": contact_info.get("google_maps_uri"),
+ "customer_name": customer.full_name if customer else None,
+ "customer_email": customer.email if customer else None,
+ "customer_phone": r.phone,
+ "customer_notes": r.notes,
+ "category_id": r.category_id,
+ "category_label": category.label if category else None,
+ "guest_count": r.guest_count,
+ "event_date": r.event_date.isoformat() if r.event_date else None,
+ "owner_id": r.owner_id,
+ "venue_id": r.venue_id,
+ "booking_id": r.booking_id,
+ "contact_method": r.contact_method,
+ "contact_notes": r.contact_notes,
+ "follow_up_date": r.follow_up_date.isoformat() if r.follow_up_date else None,
+ "owner_invited_at": r.owner_invited_at,
+ "booking_created_at": r.booking_created_at,
+ "created_at": r.created_at,
+ })
+
+ return {
+ "items": items,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "total_pages": max(1, math.ceil(total / page_size)),
+ }
+
+
+def _get_reservation_or_404(db: Session, reservation_id: UUID) -> LeadReservation:
+ from app.core.exceptions import NotFoundError
+
+ reservation = db.get(LeadReservation, reservation_id)
+ if reservation is None:
+ raise NotFoundError("External reservation not found")
+ return reservation
+
+
+def contact_reservation(
+ db: Session,
+ *,
+ admin_id: UUID,
+ reservation_id: UUID,
+ contact_method: str,
+ notes: str,
+ follow_up_date,
+) -> None:
+ from app.core.exceptions import ConflictError
+ from app.modules.admin.models import AdminAction
+
+ reservation = _get_reservation_or_404(db, reservation_id)
+ if reservation.status not in (LeadReservationStatus.NEW, LeadReservationStatus.CONTACTED):
+ raise ConflictError(f"Cannot log contact in status '{reservation.status.value}'")
+
+ reservation.contact_method = contact_method
+ reservation.contact_notes = notes
+ reservation.follow_up_date = follow_up_date
+ reservation.status = LeadReservationStatus.CONTACTED
+
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="external_reservation_contacted",
+ target_type="external_reservation", target_id=reservation_id, reason=notes or None,
+ ))
+ db.commit()
+
+
+def mark_owner_interested(db: Session, *, admin_id: UUID, reservation_id: UUID, reason: str = "") -> None:
+ from app.core.exceptions import ConflictError
+ from app.modules.admin.models import AdminAction
+
+ reservation = _get_reservation_or_404(db, reservation_id)
+ if reservation.status != LeadReservationStatus.CONTACTED:
+ raise ConflictError("Owner can only be marked interested after being contacted")
+
+ reservation.status = LeadReservationStatus.OWNER_INTERESTED
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="external_reservation_owner_interested",
+ target_type="external_reservation", target_id=reservation_id, reason=reason or None,
+ ))
+ db.commit()
+
+
+def invite_owner_for_reservation(
+ db: Session,
+ *,
+ admin_id: UUID,
+ reservation_id: UUID,
+ venue_name: str,
+ owner_name: str | None,
+ email: str,
+ phone: str | None,
+ category_id: UUID | None = None,
+) -> tuple[LeadReservation, str]:
+ from datetime import datetime, time, timezone
+
+ from app.core.exceptions import ConflictError
+ from app.modules.admin.models import AdminAction
+ from app.modules.auth.dependencies import get_auth_provider
+ from app.modules.profile.models import Profile, ProfileStatus, UserRole, UserRoleAssignment
+ from app.modules.venue.schemas import CreateVenueRequest
+ from app.modules.venue.service import create_venue
+
+ reservation = _get_reservation_or_404(db, reservation_id)
+ if reservation.status != LeadReservationStatus.OWNER_INTERESTED:
+ raise ConflictError("Owner must be marked interested before inviting them")
+
+ resolved_category_id = category_id or reservation.category_id
+ if resolved_category_id is None:
+ raise ConflictError("This reservation has no category — pass category_id to invite the owner")
+
+ provider_user, action_link = get_auth_provider().create_invite_link(
+ email, full_name=owner_name, phone=phone,
+ redirect_to=f"{settings.owner_portal_base_url}/accept-invite",
+ )
+
+ profile = db.get(Profile, provider_user.id)
+ if profile is None:
+ raise ConflictError("Invited account was not provisioned — try again")
+ if owner_name:
+ profile.full_name = owner_name
+ if phone:
+ profile.phone = phone
+ profile.status = ProfileStatus.active
+
+ if not db.query(UserRoleAssignment).filter(
+ UserRoleAssignment.user_id == provider_user.id,
+ UserRoleAssignment.role == UserRole.venue_owner,
+ ).first():
+ db.add(UserRoleAssignment(user_id=provider_user.id, role=UserRole.venue_owner))
+
+ # Same creation path (and same defaults) an owner uses through CreateVenueWizard —
+ # the draft is a completely ordinary `venues` row that the owner later edits and
+ # submits through the normal owner-portal flow.
+ from app.modules.venue.schemas import BookingType
+
+ lead = reservation.lead
+ venue = create_venue(
+ db,
+ owner_id=provider_user.id,
+ body=CreateVenueRequest(
+ name=venue_name,
+ category_id=resolved_category_id,
+ address_line1=lead.formatted_address or venue_name,
+ city=lead.city or "Unknown",
+ state="Unknown",
+ max_capacity=reservation.guest_count or 100,
+ open_time=time(9, 0),
+ close_time=time(23, 0),
+ # Single booking type keeps this consistent with the default
+ # pricing_mode ('flat'), which in turn requires a starting price —
+ # both are placeholders the owner fills in for real before submitting.
+ allowed_booking_types=[BookingType.full_day],
+ starting_price_paise=0,
+ ),
+ )
+
+ reservation.owner_id = provider_user.id
+ reservation.venue_id = venue.id
+ reservation.owner_invited_at = datetime.now(timezone.utc)
+ reservation.status = LeadReservationStatus.OWNER_INVITED
+
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="external_reservation_owner_invited",
+ target_type="external_reservation", target_id=reservation_id,
+ reason=f"Invited {email} for venue '{venue_name}'",
+ ))
+ db.commit()
+ db.refresh(reservation)
+
+ # Best-effort — email delivery must never undo the invite/venue/role work
+ # above, which already succeeded and is committed by this point.
+ try:
+ from app.core.email import send_email
+ from app.modules.notification.templates import render_owner_invite_email
+
+ subject, html = render_owner_invite_email(owner_name, venue_name, action_link)
+ send_email(email, subject, html)
+ except Exception:
+ logger.warning("Could not email invite link to %s — admin must share it manually", email, exc_info=True)
+
+ return reservation, action_link
+
+
+def create_booking_for_reservation(db: Session, *, admin_id: UUID, reservation_id: UUID):
+ from datetime import datetime, timedelta, timezone
+ from zoneinfo import ZoneInfo
+
+ from app.core.exceptions import ConflictError
+ from app.modules.admin.models import AdminAction
+ from app.modules.booking.schemas import BookingRequestIn
+ from app.modules.booking.service import create_booking_request
+ from app.modules.venue.models import Venue, VenuePhoto
+
+ reservation = _get_reservation_or_404(db, reservation_id)
+ if reservation.status != LeadReservationStatus.VENUE_APPROVED:
+ raise ConflictError("Venue must be approved before creating a booking")
+ if reservation.booking_id is not None:
+ raise ConflictError("A booking already exists for this reservation")
+ if reservation.venue_id is None:
+ raise ConflictError("Reservation has no linked venue")
+ if reservation.event_date is None:
+ raise ConflictError("Reservation has no event date")
+
+ venue = db.get(Venue, reservation.venue_id)
+ cover_photo = db.query(VenuePhoto).filter(
+ VenuePhoto.venue_id == venue.id, VenuePhoto.is_cover.is_(True), VenuePhoto.deleted_at.is_(None),
+ ).first()
+
+ # No frontend step computes starts_at/ends_at for this flow (unlike the normal
+ # booking form), so derive them the same way it would: event_date + the venue's
+ # own operating hours, localized to the venue's timezone.
+ venue_tz = ZoneInfo(venue.timezone)
+ starts_at = datetime.combine(reservation.event_date, venue.open_time, tzinfo=venue_tz)
+ end_date = reservation.event_date
+ if venue.spans_next_day and venue.close_time <= venue.open_time:
+ end_date += timedelta(days=1)
+ ends_at = datetime.combine(end_date, venue.close_time, tzinfo=venue_tz)
+
+ booking = create_booking_request(
+ db,
+ user_id=reservation.user_id,
+ payload=BookingRequestIn(
+ venue_id=venue.id,
+ venue_name=venue.name,
+ venue_cover_image=cover_photo.image_url if cover_photo else None,
+ booking_type="full_day",
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_date=reservation.event_date,
+ guest_count=reservation.guest_count or 1,
+ user_notes=reservation.notes,
+ ),
+ )
+
+ reservation.booking_id = booking.id
+ reservation.booking_created_at = datetime.now(timezone.utc)
+ reservation.status = LeadReservationStatus.BOOKING_CREATED
+
+ db.add(AdminAction(
+ admin_id=admin_id, action_type="external_reservation_booking_created",
+ target_type="external_reservation", target_id=reservation_id,
+ reason=f"Booking {booking.id} created",
+ ))
+ db.commit()
+ return booking
diff --git a/apps/api/app/modules/internal/__init__.py b/apps/api/app/modules/internal/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/internal/routes.py b/apps/api/app/modules/internal/routes.py
new file mode 100644
index 000000000..4c64e71db
--- /dev/null
+++ b/apps/api/app/modules/internal/routes.py
@@ -0,0 +1,52 @@
+"""Machine-to-machine endpoint that runs background jobs on demand.
+
+Triggered by scheduled GitHub Actions crons (see .github/workflows/jobs.yml),
+which pass the cadence-group job names and the shared X-Job-Token secret. This
+replaces the in-process APScheduler in production so the free web service can
+sleep when idle (ENABLE_JOBS stays false).
+"""
+import hmac
+import logging
+
+from fastapi import APIRouter, Header, HTTPException, Query
+
+from app.core.config import settings
+from app.jobs.runner import JOBS, run_job
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+def _authorize(x_job_token: str | None) -> None:
+ if not settings.job_runner_token:
+ # No token configured -> endpoint is intentionally disabled.
+ raise HTTPException(status_code=503, detail="Job runner is not configured")
+ if not x_job_token or not hmac.compare_digest(x_job_token, settings.job_runner_token):
+ raise HTTPException(status_code=401, detail="Invalid job token")
+
+
+@router.post("/run-jobs")
+def run_jobs(
+ jobs: str = Query(..., description="Comma-separated job names, e.g. hold_expiry,payment_reminders"),
+ x_job_token: str | None = Header(default=None, alias="X-Job-Token"),
+):
+ _authorize(x_job_token)
+
+ names = [n.strip() for n in jobs.split(",") if n.strip()]
+ if not names:
+ raise HTTPException(status_code=400, detail="No jobs specified")
+
+ unknown = [n for n in names if n not in JOBS]
+ if unknown:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown job(s): {', '.join(unknown)}. Valid: {', '.join(JOBS)}",
+ )
+
+ results: dict[str, int] = {}
+ for name in names:
+ results[name] = run_job(name)
+
+ logger.info("run-jobs completed: %s", results)
+ return {"results": results, "ran": len(results)}
diff --git a/apps/api/app/modules/notification/models.py b/apps/api/app/modules/notification/models.py
new file mode 100644
index 000000000..150900fcb
--- /dev/null
+++ b/apps/api/app/modules/notification/models.py
@@ -0,0 +1,26 @@
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Text, ForeignKey, DateTime, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import mapped_column, Mapped
+from app.core.database import Base
+
+
+class InAppNotification(Base):
+ __tablename__ = "notifications"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False
+ )
+ booking_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="CASCADE"), nullable=True
+ )
+ type: Mapped[str] = mapped_column(String, nullable=False)
+ title: Mapped[str] = mapped_column(String, nullable=False)
+ body: Mapped[str] = mapped_column(Text, nullable=False)
+ read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, default=func.now()
+ )
diff --git a/apps/api/app/modules/notification/routes.py b/apps/api/app/modules/notification/routes.py
new file mode 100644
index 000000000..1715c1c43
--- /dev/null
+++ b/apps/api/app/modules/notification/routes.py
@@ -0,0 +1,26 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import get_current_user, AuthContext
+from app.modules.notification.schemas import NotificationResponse
+from app.modules.notification import service
+
+router = APIRouter()
+
+
+@router.get("/", response_model=list[NotificationResponse])
+def list_notifications(
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return service.list_notifications(db, user.user_id)
+
+
+@router.patch("/{notification_id}/read", status_code=204)
+def mark_read(
+ notification_id: str,
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ service.mark_read(db, notification_id, user.user_id)
diff --git a/apps/api/app/modules/notification/schemas.py b/apps/api/app/modules/notification/schemas.py
new file mode 100644
index 000000000..3a6fe0105
--- /dev/null
+++ b/apps/api/app/modules/notification/schemas.py
@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+from datetime import datetime
+
+
+class NotificationResponse(BaseModel):
+ id: str
+ user_id: str
+ booking_id: str | None
+ type: str
+ title: str
+ body: str
+ read_at: datetime | None
+ created_at: datetime
diff --git a/apps/api/app/modules/notification/service.py b/apps/api/app/modules/notification/service.py
new file mode 100644
index 000000000..6e3ee100c
--- /dev/null
+++ b/apps/api/app/modules/notification/service.py
@@ -0,0 +1,142 @@
+import json
+import logging
+import urllib.request
+import urllib.error
+from datetime import datetime, timezone
+from typing import List
+
+from sqlalchemy import event
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.core.email import send_email
+from app.core.exceptions import NotFoundError, ForbiddenError
+from app.modules.notification.models import InAppNotification
+from app.modules.notification.schemas import NotificationResponse
+from app.modules.notification.templates import render_notification
+
+logger = logging.getLogger(__name__)
+
+
+def notify(
+ db: Session, user_id, type: str, context: dict | None = None, booking_id=None,
+ skip_email: bool = False,
+) -> InAppNotification:
+ """Create an in-app notification and queue its email for after-commit send.
+
+ Always writes the in-app row inside the caller's transaction. The email is
+ deferred: it is sent only once the transaction commits (see the after_commit
+ listener), so no network I/O happens while the caller still holds DB row
+ locks, and a rollback sends nothing. Email delivery is best-effort and never
+ affects the transaction. The caller owns the commit.
+
+ skip_email=True writes the in-app row but queues no email — used when a
+ fuller email (e.g. booking-confirmed + invoice, see
+ app.modules.booking.invoice) will be sent later by an async job instead.
+ """
+ title, body, html = render_notification(type, context, booking_id)
+ row = InAppNotification(user_id=user_id, booking_id=booking_id, type=type, title=title, body=body)
+ db.add(row)
+ db.flush()
+
+ if not skip_email:
+ pending = db.info.setdefault("_pending_emails", [])
+ pending.append({"user_id": str(user_id), "subject": title, "html": html, "notification_id": row.id})
+ return row
+
+
+def _send_pending_emails(session: Session) -> None:
+ """after_commit hook: send any emails queued by notify() during this txn.
+
+ Runs after the lock-holding transaction has committed. sent_at is stamped via
+ a fresh short-lived session so we never re-enter the just-committed one.
+ """
+ pending = session.info.pop("_pending_emails", None)
+ if not pending:
+ return
+ for item in pending:
+ try:
+ email = _get_user_email(item["user_id"])
+ if email and send_email(email, item["subject"], item["html"]):
+ _mark_email_sent(item["notification_id"])
+ except Exception:
+ logger.exception("Deferred notification email failed (notification=%s)", item.get("notification_id"))
+
+
+def _mark_email_sent(notification_id) -> None:
+ s = SessionLocal()
+ try:
+ row = s.get(InAppNotification, notification_id)
+ if row and row.sent_at is None:
+ row.sent_at = datetime.now(timezone.utc)
+ s.commit()
+ except Exception:
+ logger.exception("Could not stamp sent_at for notification %s", notification_id)
+ finally:
+ s.close()
+
+
+# Drain queued emails whenever any app session commits. The fresh session opened
+# in _mark_email_sent re-fires this with an empty queue, so there is no loop.
+event.listen(SessionLocal, "after_commit", _send_pending_emails)
+
+
+def list_notifications(db: Session, user_id) -> List[NotificationResponse]:
+ rows = (
+ db.query(InAppNotification)
+ .filter(InAppNotification.user_id == user_id)
+ .order_by(InAppNotification.created_at.desc())
+ .all()
+ )
+ return [_to_response(r) for r in rows]
+
+
+def mark_read(db: Session, notification_id: str, user_id) -> None:
+ row = db.query(InAppNotification).filter(InAppNotification.id == notification_id).first()
+ if not row:
+ raise NotFoundError("Notification not found")
+ if str(row.user_id) != str(user_id):
+ raise ForbiddenError("Not your notification")
+ if row.read_at is None:
+ row.read_at = datetime.now(timezone.utc)
+ db.commit()
+
+
+def _to_response(r: InAppNotification) -> NotificationResponse:
+ return NotificationResponse(
+ id=str(r.id),
+ user_id=str(r.user_id),
+ booking_id=str(r.booking_id) if r.booking_id else None,
+ type=r.type,
+ title=r.title,
+ body=r.body,
+ read_at=r.read_at,
+ created_at=r.created_at,
+ )
+
+
+def _get_user_email(user_id) -> str | None:
+ """Resolve a user's email via the Supabase Auth Admin API.
+
+ Email lives in auth.users (Supabase-managed), not in profiles, so jobs and
+ webhooks (which have no JWT) look it up here. Returns None on any failure.
+ """
+ if not settings.supabase_url or not settings.supabase_service_role_key:
+ return None
+ url = f"{settings.supabase_url}/auth/v1/admin/users/{user_id}"
+ req = urllib.request.Request(
+ url,
+ method="GET",
+ headers={
+ "Authorization": f"Bearer {settings.supabase_service_role_key}",
+ "apikey": settings.supabase_service_role_key,
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
+ data = json.loads(resp.read())
+ return data.get("email")
+ except (urllib.error.URLError, json.JSONDecodeError, KeyError):
+ logger.warning("Could not resolve email for user %s", user_id)
+ return None
diff --git a/apps/api/app/modules/notification/templates.py b/apps/api/app/modules/notification/templates.py
new file mode 100644
index 000000000..367525140
--- /dev/null
+++ b/apps/api/app/modules/notification/templates.py
@@ -0,0 +1,305 @@
+"""Notification copy + email HTML, keyed by event type.
+
+render_notification(type, context, booking_id) -> (title, body, html)
+`context` keys are best-effort; missing keys degrade gracefully.
+"""
+from app.core.config import settings
+
+# type -> (title, body template). Body templates use str.format(**context).
+_TEMPLATES: dict[str, tuple[str, str]] = {
+ "request_received": (
+ "Booking request received",
+ "We received your request for {venue_name}. The owner will respond soon.",
+ ),
+ "new_request_owner": (
+ "New booking request",
+ "You have a new booking request for {venue_name}.",
+ ),
+ "request_accepted": (
+ "Your booking was accepted",
+ "Pay the token advance within 24 hours to confirm your booking for {venue_name}.",
+ ),
+ "booking_rejected": (
+ "Booking request declined",
+ "The owner could not accept your request for {venue_name}.",
+ ),
+ "payment_reminder": (
+ "Reminder: complete your payment",
+ "Your 24-hour hold for {venue_name} is expiring soon. Pay the token advance to confirm.",
+ ),
+ "payment_confirmed": (
+ "Booking confirmed",
+ "Your booking for {venue_name} is confirmed. We look forward to your event!",
+ ),
+ "balance_paid": (
+ "Balance paid — booking fully paid",
+ "Thanks! Your balance for {venue_name} is paid and your booking is fully settled.",
+ ),
+ "balance_overdue": (
+ "Balance payment overdue",
+ "The remaining balance for {venue_name} is overdue. Please pay it to keep your booking.",
+ ),
+ "balance_deadline_extended": (
+ "Balance deadline extended",
+ "Your balance payment deadline for {venue_name} has been extended.",
+ ),
+ "hold_expired": (
+ "Payment hold expired",
+ "Your 24-hour hold for {venue_name} has expired and the slot was released.",
+ ),
+ "conflict_canceled": (
+ "Booking unavailable",
+ "Another guest confirmed {venue_name} first. Any payment you made has been refunded.",
+ ),
+ "booking_canceled": (
+ "Booking canceled",
+ "Your booking for {venue_name} was canceled.",
+ ),
+ "request_expired": (
+ "Request expired",
+ "Your booking request for {venue_name} expired before the owner responded.",
+ ),
+ "refund_issued": (
+ "Refund issued",
+ "A refund of ₹{amount_rupees} has been issued for {venue_name}.",
+ ),
+ "booking_completed": (
+ "Booking completed",
+ "Your event at {venue_name} is marked complete. Thanks for using Venue404!",
+ ),
+ "venue_approved": (
+ "Your venue is now live",
+ "Great news — {venue_name} has been approved and is now visible to customers on Venue404.",
+ ),
+ "venue_rejected": (
+ "Venue submission rejected",
+ "Your venue {venue_name} was not approved. Reason: {reason}",
+ ),
+ "venue_suspended": (
+ "Venue suspended",
+ "Your venue {venue_name} has been suspended and is no longer visible to customers. Reason: {reason}",
+ ),
+ "venue_reactivated": (
+ "Venue reactivated",
+ "Good news — {venue_name} has been reactivated and is visible to customers again.",
+ ),
+ "user_suspended": (
+ "Your account has been suspended",
+ "Your Venue404 account has been suspended. Reason: {reason}",
+ ),
+ "user_reactivated": (
+ "Your account has been reactivated",
+ "Your Venue404 account has been reactivated — you can log in and use Venue404 again.",
+ ),
+}
+
+# Spelling/legacy aliases -> canonical template key. Both British and American
+# spellings are valid, so either resolves to the same copy. Older raw strings
+# used by callers are mapped here too, so nothing falls back to the generic copy.
+_ALIASES: dict[str, str] = {
+ "booking_cancelled": "booking_canceled",
+ "conflict_cancelled": "conflict_canceled",
+ "booking_confirmed": "payment_confirmed",
+ "booking_requested": "new_request_owner",
+ "booking_accepted": "request_accepted",
+}
+
+
+def render_notification(
+ type: str, context: dict | None = None, booking_id=None
+) -> tuple[str, str, str]:
+ context = {"venue_name": "your venue", "reason": "No reason provided.", **(context or {})}
+ type = _ALIASES.get(type, type)
+ title, body_tmpl = _TEMPLATES.get(type, ("Notification", "You have a new notification."))
+ try:
+ body = body_tmpl.format(**context)
+ except (KeyError, IndexError):
+ body = body_tmpl # leave placeholders rather than crash
+
+ cta_url = None
+ if booking_id is not None:
+ cta_url = f"{settings.frontend_base_url}/bookings/{booking_id}"
+
+ html = _email_layout(
+ title=title,
+ body_html=f"
{body}
",
+ cta_text="View booking" if cta_url else None,
+ cta_url=cta_url,
+ )
+ return title, body, html
+
+
+# ─── Shared branded layout ──────────────────────────────────────────────────
+# One inline-styled, table-based layout (email-client-safe) used by every
+# outbound email — transactional notifications above and the auth emails
+# below — so all of Venue404's mail looks like it came from the same product
+# instead of ad hoc
+ Venue404 — venue discovery & booking marketplace.
+ This is an automated email — please don't reply directly to it.
+
+
+
+
+
+
+
+
+
+"""
+
+
+# ─── Auth + owner-lifecycle emails ─────────────────────────────────────────
+# Not tied to a booking_id or an in-app notification row like the templates
+# above, but kept in this file so all outbound email copy lives in one place.
+
+def render_owner_invite_email(owner_name: str | None, venue_name: str, action_link: str) -> tuple[str, str]:
+ subject = "You're invited to manage your venue on Venue404"
+ html = _email_layout(
+ title="You've been invited to Venue404",
+ body_html=(
+ f"
{owner_name or 'Hi'}, an admin has invited you to manage "
+ f"{venue_name} on Venue404.
"
+ ),
+ cta_text="Set your password and get started",
+ cta_url=action_link,
+ footnote="If you weren't expecting this, you can safely ignore this email.",
+ )
+ return subject, html
+
+
+def render_password_reset_email(action_link: str) -> tuple[str, str]:
+ subject = "Reset your Venue404 password"
+ html = _email_layout(
+ title="Reset your password",
+ body_html="
We received a request to reset your Venue404 password.
",
+ cta_text="Set a new password",
+ cta_url=action_link,
+ footnote="If you didn't request this, you can safely ignore this email — your password won't change.",
+ )
+ return subject, html
+
+
+def render_booking_invoice_email(customer_name: str | None, venue_name: str, pdf_url: str) -> tuple[str, str]:
+ """The customer's single booking-confirmed email — deliberately held back
+ by confirm_payment (notify(..., skip_email=True)) and sent from here once
+ the invoice PDF exists, so the customer gets one email with the invoice
+ attached rather than a confirmation email followed by a separate one.
+ """
+ subject = f"Booking confirmed — your Venue404 invoice for {venue_name}"
+ html = _email_layout(
+ title="Your booking is confirmed",
+ body_html=(
+ f"
{customer_name or 'Hi'}, your booking for {venue_name} is "
+ "confirmed. We look forward to your event!
"
+ ),
+ cta_text="Download your invoice",
+ cta_url=pdf_url,
+ )
+ return subject, html
+
+
+def render_owner_approved_email(owner_name: str | None) -> tuple[str, str]:
+ subject = "You're approved as a Venue404 owner"
+ html = _email_layout(
+ title="Your owner account is approved",
+ body_html=(
+ f"
{owner_name or 'Hi'}, your Venue404 venue owner application has been "
+ "approved. You can now list venues and start receiving booking requests.
"
+ ),
+ cta_text="Go to your owner dashboard",
+ cta_url=f"{settings.owner_portal_base_url}",
+ )
+ return subject, html
+
+
+def render_owner_rejected_email(owner_name: str | None, reason: str = "") -> tuple[str, str]:
+ subject = "Update on your Venue404 owner application"
+ reason_html = f"
Reason: {reason}
" if reason else ""
+ html = _email_layout(
+ title="Your owner application wasn't approved",
+ body_html=(
+ f"
{owner_name or 'Hi'}, we've reviewed your Venue404 venue owner application "
+ "and it wasn't approved this time.
"
+ f"{reason_html}"
+ "
You're welcome to re-apply once you've addressed the above.
"
+ ),
+ )
+ return subject, html
diff --git a/apps/api/app/modules/notification/templates/.gitkeep b/apps/api/app/modules/notification/templates/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/notification/types.py b/apps/api/app/modules/notification/types.py
new file mode 100644
index 000000000..3fe86e163
--- /dev/null
+++ b/apps/api/app/modules/notification/types.py
@@ -0,0 +1,31 @@
+"""Canonical notification type identifiers.
+
+Producers (booking, payment, jobs) should reference these constants instead of
+raw strings so a type can never drift from a template key. Both British and
+American spellings resolve to the same template (see ALIASES in templates.py),
+so callers are free to use either spelling.
+"""
+
+
+class NotificationType:
+ REQUEST_RECEIVED = "request_received"
+ NEW_REQUEST_OWNER = "new_request_owner"
+ REQUEST_ACCEPTED = "request_accepted"
+ BOOKING_REJECTED = "booking_rejected"
+ PAYMENT_REMINDER = "payment_reminder"
+ PAYMENT_CONFIRMED = "payment_confirmed"
+ BALANCE_PAID = "balance_paid"
+ BALANCE_OVERDUE = "balance_overdue"
+ BALANCE_DEADLINE_EXTENDED = "balance_deadline_extended"
+ HOLD_EXPIRED = "hold_expired"
+ CONFLICT_CANCELED = "conflict_canceled"
+ BOOKING_CANCELED = "booking_canceled"
+ REQUEST_EXPIRED = "request_expired"
+ REFUND_ISSUED = "refund_issued"
+ BOOKING_COMPLETED = "booking_completed"
+ VENUE_APPROVED = "venue_approved"
+ VENUE_REJECTED = "venue_rejected"
+ VENUE_SUSPENDED = "venue_suspended"
+ VENUE_REACTIVATED = "venue_reactivated"
+ USER_SUSPENDED = "user_suspended"
+ USER_REACTIVATED = "user_reactivated"
diff --git a/apps/api/app/modules/owner/__init__.py b/apps/api/app/modules/owner/__init__.py
new file mode 100644
index 000000000..647a7d3f1
--- /dev/null
+++ b/apps/api/app/modules/owner/__init__.py
@@ -0,0 +1 @@
+# Owner module
diff --git a/apps/api/app/modules/owner/routes.py b/apps/api/app/modules/owner/routes.py
new file mode 100644
index 000000000..c09f0931e
--- /dev/null
+++ b/apps/api/app/modules/owner/routes.py
@@ -0,0 +1,37 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import AuthContext, require_owner
+from app.modules.owner import service
+from app.modules.owner.schemas import DashboardStats, ChartDataPoint, UpcomingEventOut
+
+router = APIRouter()
+
+
+@router.get("/dashboard/stats", response_model=DashboardStats)
+def get_owner_dashboard_stats(
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ """Aggregated KPI and financial stats for the owner dashboard."""
+ return service.get_dashboard_stats(db, auth.user_id)
+
+
+@router.get("/dashboard/chart", response_model=list[ChartDataPoint])
+def get_owner_dashboard_chart(
+ time_range: str = "6M",
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ """Monthly performance chart data for the owner dashboard."""
+ return service.get_dashboard_chart(db, auth.user_id, time_range)
+
+
+@router.get("/dashboard/upcoming-events", response_model=list[UpcomingEventOut])
+def get_owner_dashboard_upcoming_events(
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ """Upcoming confirmed events for the owner dashboard."""
+ return service.get_upcoming_events(db, auth.user_id)
diff --git a/apps/api/app/modules/owner/schemas.py b/apps/api/app/modules/owner/schemas.py
new file mode 100644
index 000000000..527f1280f
--- /dev/null
+++ b/apps/api/app/modules/owner/schemas.py
@@ -0,0 +1,37 @@
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel
+
+
+class DashboardStats(BaseModel):
+ active_venues: int
+ pending_requests: int
+ active_bookings: int
+ completed_bookings: int
+ cancelled_bookings: int
+ # Financial (paise)
+ gross_volume_paise: int
+ net_revenue_paise: int
+ available_balance_paise: int
+ platform_fees_paise: int
+ refunds_issued_paise: int
+ payouts_completed_paise: int
+
+
+class ChartDataPoint(BaseModel):
+ month: str # e.g. "Jan 26"
+ enquiries: int
+ completed: int
+ cancelled: int
+
+
+class UpcomingEventOut(BaseModel):
+ booking_id: str
+ event_type: Optional[str] = None
+ venue_name: str
+ status: str
+ starts_at: Optional[str] = None # ISO string
+ guest_count: int
+
+
+
diff --git a/apps/api/app/modules/owner/service.py b/apps/api/app/modules/owner/service.py
new file mode 100644
index 000000000..73884ff1d
--- /dev/null
+++ b/apps/api/app/modules/owner/service.py
@@ -0,0 +1,252 @@
+"""
+Owner dashboard service.
+
+Returns a single aggregated response containing:
+ - KPI counts (pending requests, active bookings, completed, cancelled)
+ - Financial stats (reuses ledger logic from payment service)
+ - Monthly performance chart data (last 6 months)
+ - Upcoming confirmed events (next 5)
+"""
+from datetime import datetime, timezone, timedelta
+from uuid import UUID
+from calendar import month_abbr
+
+from sqlalchemy.orm import Session
+from sqlalchemy import func, case
+
+from app.modules.booking.models import Booking, BookingStatus, BookingSlot
+from app.modules.venue.models import Venue, VenueStatus
+from app.modules.payment.models import LedgerEntry
+from app.modules.owner.schemas import (
+ DashboardStats,
+ ChartDataPoint,
+ UpcomingEventOut,
+)
+
+# Statuses considered "cancelled/terminal non-success"
+CANCELLED_STATUSES = [
+ BookingStatus.conflict_cancelled,
+ BookingStatus.user_cancelled,
+ BookingStatus.admin_cancelled,
+ BookingStatus.owner_rejected,
+ BookingStatus.balance_overdue_cancelled,
+ BookingStatus.hold_expired,
+ BookingStatus.request_expired,
+]
+
+
+def get_dashboard_stats(db: Session, owner_id: UUID) -> DashboardStats:
+ # ------------------------------------------------------------------ #
+ # 1. Booking counts — one query, grouped by status
+ # ------------------------------------------------------------------ #
+ active_venues = (
+ db.query(func.count(Venue.id))
+ .filter(
+ Venue.owner_id == owner_id,
+ Venue.is_active == True,
+ Venue.status == VenueStatus.approved,
+ Venue.deleted_at.is_(None)
+ )
+ .scalar() or 0
+ )
+
+ status_counts: dict[str, int] = {}
+
+ rows = (
+ db.query(Booking.status, func.count(Booking.id).label("cnt"))
+ .join(Venue, Booking.venue_id == Venue.id)
+ .filter(
+ Venue.owner_id == owner_id,
+ Booking.deleted_at.is_(None),
+ )
+ .group_by(Booking.status)
+ .all()
+ )
+ for status_val, cnt in rows:
+ status_counts[status_val] = cnt
+
+ pending_requests = status_counts.get(BookingStatus.requested, 0)
+ active_bookings = status_counts.get(BookingStatus.confirmed, 0)
+ completed_bookings = status_counts.get(BookingStatus.completed, 0)
+ cancelled_bookings = sum(
+ status_counts.get(s, 0) for s in CANCELLED_STATUSES
+ )
+
+ # ------------------------------------------------------------------ #
+ # 2. Financial stats — from ledger entries
+ # ------------------------------------------------------------------ #
+ ledger_rows = (
+ db.query(
+ LedgerEntry.entry_type,
+ LedgerEntry.direction,
+ func.sum(LedgerEntry.amount_paise).label("total"),
+ )
+ .filter(LedgerEntry.owner_id == owner_id)
+ .group_by(LedgerEntry.entry_type, LedgerEntry.direction)
+ .all()
+ )
+
+ gross_volume = 0
+ platform_fees = 0
+ refunds_issued = 0
+ payouts_completed = 0
+
+ for entry_type, direction, total in ledger_rows:
+ val = int(total or 0)
+ if entry_type == "charge" and direction == "credit":
+ gross_volume += val
+ elif entry_type == "platform_fee" and direction == "debit":
+ platform_fees += val
+ elif entry_type == "refund" and direction == "debit":
+ refunds_issued += val
+ elif entry_type == "payout" and direction == "debit":
+ payouts_completed += val
+
+ net_revenue = gross_volume - platform_fees - refunds_issued
+ available_balance = net_revenue - payouts_completed
+
+ stats = DashboardStats(
+ active_venues=active_venues,
+ pending_requests=pending_requests,
+ active_bookings=active_bookings,
+ completed_bookings=completed_bookings,
+ cancelled_bookings=cancelled_bookings,
+ gross_volume_paise=gross_volume,
+ net_revenue_paise=net_revenue,
+ available_balance_paise=available_balance,
+ platform_fees_paise=platform_fees,
+ refunds_issued_paise=refunds_issued,
+ payouts_completed_paise=payouts_completed,
+ )
+
+ return stats
+
+
+def get_dashboard_chart(db: Session, owner_id: UUID, time_range: str = "6M") -> list[ChartDataPoint]:
+ # ------------------------------------------------------------------ #
+ # Chart data — dynamic time range
+ # ------------------------------------------------------------------ #
+ now = datetime.now(timezone.utc)
+ is_daily = time_range in ("7D", "30D")
+
+ num_buckets = 6
+ if time_range == "7D": num_buckets = 7
+ elif time_range == "30D": num_buckets = 30
+ elif time_range == "3M": num_buckets = 3
+ elif time_range == "12M": num_buckets = 12
+
+ buckets: list[tuple[int, int, int]] = []
+
+ if is_daily:
+ for delta in range(num_buckets - 1, -1, -1):
+ t = now - timedelta(days=delta)
+ buckets.append((t.year, t.month, t.day))
+ start_of_window = datetime(buckets[0][0], buckets[0][1], buckets[0][2], tzinfo=timezone.utc)
+ else:
+ for delta in range(num_buckets - 1, -1, -1):
+ y = now.year
+ m = now.month - delta
+ while m < 1:
+ m += 12
+ y -= 1
+ buckets.append((y, m, 1))
+ start_of_window = datetime(buckets[0][0], buckets[0][1], 1, tzinfo=timezone.utc)
+
+ if is_daily:
+ group_fields = [
+ func.extract("year", Booking.created_at).label("yr"),
+ func.extract("month", Booking.created_at).label("mo"),
+ func.extract("day", Booking.created_at).label("day"),
+ ]
+ else:
+ group_fields = [
+ func.extract("year", Booking.created_at).label("yr"),
+ func.extract("month", Booking.created_at).label("mo"),
+ ]
+
+ chart_bookings = (
+ db.query(Booking.status, func.count(Booking.id).label("cnt"), *group_fields)
+ .join(Venue, Booking.venue_id == Venue.id)
+ .filter(
+ Venue.owner_id == owner_id,
+ Booking.deleted_at.is_(None),
+ Booking.created_at >= start_of_window,
+ )
+ .group_by(Booking.status, *group_fields)
+ .all()
+ )
+
+ bucket_map: dict[tuple[int, int, int], dict[str, int]] = {
+ b: {"enquiries": 0, "completed": 0, "cancelled": 0}
+ for b in buckets
+ }
+
+ for row in chart_bookings:
+ status_val = row.status
+ cnt = row.cnt
+ yr = int(row.yr)
+ mo = int(row.mo)
+ day = int(row.day) if is_daily else 1
+
+ key = (yr, mo, day)
+ if key not in bucket_map:
+ continue
+
+ # Every booking created is considered an enquiry, regardless of its final status
+ bucket_map[key]["enquiries"] += cnt
+
+ if status_val == BookingStatus.completed:
+ bucket_map[key]["completed"] += cnt
+ elif status_val in CANCELLED_STATUSES:
+ bucket_map[key]["cancelled"] += cnt
+
+ chart_data = []
+ for b in buckets:
+ dt = datetime(b[0], b[1], b[2])
+ month_label = dt.strftime("%d %b") if is_daily else dt.strftime("%b %y")
+
+ chart_data.append(
+ ChartDataPoint(
+ month=month_label,
+ enquiries=bucket_map[b]["enquiries"],
+ completed=bucket_map[b]["completed"],
+ cancelled=bucket_map[b]["cancelled"],
+ )
+ )
+
+ return chart_data
+
+
+def get_upcoming_events(db: Session, owner_id: UUID) -> list[UpcomingEventOut]:
+ # ------------------------------------------------------------------ #
+ # Upcoming confirmed events — next 5
+ # ------------------------------------------------------------------ #
+ now = datetime.now(timezone.utc)
+ upcoming_rows = (
+ db.query(Booking, BookingSlot, Venue)
+ .join(BookingSlot, BookingSlot.booking_id == Booking.id)
+ .join(Venue, Booking.venue_id == Venue.id)
+ .filter(
+ Venue.owner_id == owner_id,
+ Booking.deleted_at.is_(None),
+ Booking.status == BookingStatus.confirmed,
+ BookingSlot.starts_at > now,
+ )
+ .order_by(BookingSlot.starts_at.asc())
+ .limit(5)
+ .all()
+ )
+
+ upcoming_events = [
+ UpcomingEventOut(
+ booking_id=str(booking.id),
+ event_type=booking.event_type,
+ venue_name=venue.name,
+ status=booking.status,
+ starts_at=slot.starts_at.isoformat() if slot.starts_at else None,
+ guest_count=booking.guest_count,
+ )
+ for booking, slot, venue in upcoming_rows
+ ]
+
+ return upcoming_events
diff --git a/apps/api/app/modules/payment/models.py b/apps/api/app/modules/payment/models.py
new file mode 100644
index 000000000..25883b63d
--- /dev/null
+++ b/apps/api/app/modules/payment/models.py
@@ -0,0 +1,137 @@
+import enum
+import uuid
+from datetime import datetime
+from sqlalchemy import String, BigInteger, Text, Enum, ForeignKey, DateTime, func
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import mapped_column, Mapped
+from app.core.database import Base
+from app.shared.models import TimestampMixin
+
+
+class PaymentAttemptStatus(str, enum.Enum):
+ pending = "pending"
+ succeeded = "succeeded"
+ failed = "failed"
+ refunded = "refunded"
+
+
+class RefundStatus(str, enum.Enum):
+ pending = "pending"
+ succeeded = "succeeded"
+ failed = "failed"
+
+
+class PayoutStatus(str, enum.Enum):
+ requested = "requested"
+ processing = "processing"
+ paid = "paid"
+ failed = "failed"
+
+
+class Payment(Base, TimestampMixin):
+ """Read-model of a single Stripe charge attempt for a booking."""
+ __tablename__ = "payments"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False
+ )
+ amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ currency: Mapped[str] = mapped_column(String, nullable=False, default="inr")
+ status: Mapped[PaymentAttemptStatus] = mapped_column(
+ Enum(PaymentAttemptStatus, name="payment_attempt_status", create_constraint=False),
+ nullable=False,
+ default=PaymentAttemptStatus.pending,
+ )
+ stripe_payment_intent_id: Mapped[str] = mapped_column(String, nullable=False)
+ stripe_client_secret: Mapped[str | None] = mapped_column(String, nullable=True)
+ # "advance" | "balance" — lets the webhook route a capture to the right
+ # confirmation path (advance confirms the booking; balance settles it).
+ # server_default keeps autogenerated NOT NULL adds safe on a populated table.
+ payment_type: Mapped[str] = mapped_column(
+ String, nullable=False, default="advance", server_default="advance"
+ )
+
+
+class Refund(Base, TimestampMixin):
+ __tablename__ = "refunds"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ payment_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("payments.id", ondelete="RESTRICT"), nullable=False
+ )
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False
+ )
+ amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+ status: Mapped[RefundStatus] = mapped_column(
+ Enum(RefundStatus, name="refund_status", create_constraint=False),
+ nullable=False,
+ default=RefundStatus.pending,
+ )
+ stripe_refund_id: Mapped[str | None] = mapped_column(String, nullable=True)
+
+
+class LedgerEntry(Base):
+ """Append-only record of every money movement — the single source of truth.
+ `payments` / `refunds` are convenience read-models over these events.
+ """
+ __tablename__ = "ledger_entries"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False
+ )
+ venue_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("venues.id", ondelete="RESTRICT"), nullable=False
+ )
+ owner_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False
+ )
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False
+ )
+ entry_type: Mapped[str] = mapped_column(String, nullable=False) # charge|refund|payout|platform_fee
+ amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ direction: Mapped[str] = mapped_column(String, nullable=False) # credit|debit
+ stripe_pi_ref: Mapped[str | None] = mapped_column(String, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, default=func.now()
+ )
+
+
+class PayoutRequest(Base, TimestampMixin):
+ __tablename__ = "payout_requests"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ owner_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="RESTRICT"), nullable=False
+ )
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="RESTRICT"), nullable=False
+ )
+ amount_paise: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ status: Mapped[PayoutStatus] = mapped_column(
+ Enum(PayoutStatus, name="payout_status", create_constraint=False),
+ nullable=False,
+ default=PayoutStatus.requested,
+ )
+ stripe_transfer_id: Mapped[str | None] = mapped_column(String, nullable=True)
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
+class StripeEvent(Base):
+ """Webhook idempotency guard. PK is the Stripe event id, so a duplicate
+ insert (replayed webhook) raises IntegrityError and the handler no-ops.
+ """
+ __tablename__ = "stripe_events"
+
+ id: Mapped[str] = mapped_column(String, primary_key=True) # Stripe event id
+ type: Mapped[str] = mapped_column(String, nullable=False)
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ processing_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ raw_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, default=func.now()
+ )
diff --git a/apps/api/app/modules/payment/routes.py b/apps/api/app/modules/payment/routes.py
new file mode 100644
index 000000000..aef8a0a13
--- /dev/null
+++ b/apps/api/app/modules/payment/routes.py
@@ -0,0 +1,65 @@
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import get_current_user, AuthContext
+from app.modules.payment.schemas import (
+ CreatePaymentRequest, PaymentIntentResponse, PaymentResponse,
+ RefundRequest, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse
+)
+from typing import Optional
+from app.modules.payment import service, webhooks
+
+router = APIRouter()
+
+
+@router.get("/owner/stats", response_model=OwnerLedgerStatsResponse)
+def get_owner_stats(
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return service.get_owner_ledger_stats(db, user)
+
+
+@router.get("/owner/ledger", response_model=list[LedgerEntryResponse])
+def get_owner_ledger(
+ entry_type: Optional[str] = None,
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return service.list_owner_ledger_entries(db, user, entry_type)
+
+
+@router.post("/", response_model=PaymentIntentResponse, status_code=201)
+def create_payment(
+ body: CreatePaymentRequest,
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """Create a Stripe PaymentIntent for a booking's token advance or balance."""
+ return service.create_payment_intent(db, user.user_id, body.booking_id, body.payment_type)
+
+
+@router.post("/webhook")
+async def stripe_webhook(request: Request):
+ """Stripe calls this — no auth dependency; verified by signature instead."""
+ return await webhooks.handle(request)
+
+
+@router.post("/refund", response_model=RefundResponse)
+def refund(
+ body: RefundRequest,
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """Owner/admin full refund of a booking's captured payment."""
+ return service.refund_booking(db, body.booking_id, user, body.reason)
+
+
+@router.get("/{booking_id}", response_model=list[PaymentResponse])
+def get_payments(
+ booking_id: str,
+ user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return service.list_payments_for_booking(db, booking_id, user)
diff --git a/apps/api/app/modules/payment/schemas.py b/apps/api/app/modules/payment/schemas.py
new file mode 100644
index 000000000..14b2c7228
--- /dev/null
+++ b/apps/api/app/modules/payment/schemas.py
@@ -0,0 +1,61 @@
+from typing import Literal
+
+from pydantic import BaseModel
+
+
+class CreatePaymentRequest(BaseModel):
+ booking_id: str
+ # "advance" (token, confirms the booking) or "balance" (settles a confirmed
+ # booking). Amount is computed server-side from the venue pricing snapshot —
+ # never trusted from the client.
+ payment_type: Literal["advance", "balance", "full"] = "advance"
+
+
+class PaymentIntentResponse(BaseModel):
+ payment_id: str
+ booking_id: str
+ client_secret: str | None
+ amount_paise: int
+ currency: str
+ status: str
+
+
+class PaymentResponse(BaseModel):
+ id: str
+ booking_id: str
+ amount_paise: int
+ currency: str
+ status: str
+ stripe_payment_intent_id: str
+
+
+class RefundRequest(BaseModel):
+ booking_id: str
+ reason: str | None = None
+
+
+class RefundResponse(BaseModel):
+ booking_id: str
+ refunded_paise: int
+ status: str
+
+
+class OwnerLedgerStatsResponse(BaseModel):
+ gross_volume_paise: int
+ platform_fees_paise: int
+ refunds_issued_paise: int
+ net_revenue_paise: int
+ payouts_completed_paise: int
+ available_balance_paise: int
+
+class LedgerEntryResponse(BaseModel):
+ id: str
+ booking_id: str
+ venue_id: str
+ venue_name: str | None = None
+ user_full_name: str | None = None
+ entry_type: str
+ amount_paise: int
+ direction: str
+ stripe_pi_ref: str | None = None
+ created_at: str
diff --git a/apps/api/app/modules/payment/service.py b/apps/api/app/modules/payment/service.py
new file mode 100644
index 000000000..3f4ffe07d
--- /dev/null
+++ b/apps/api/app/modules/payment/service.py
@@ -0,0 +1,635 @@
+"""Payment service — Stripe charge/refund logic with race-safe confirmation.
+
+Invariants enforced (see CLAUDE.md):
+ * money is integer paise, never float
+ * confirmation is transactional with row locks; the booking_slots GIST
+ exclusion guarantees only one confirmed booking per slot
+ * losing payers are auto-refunded
+ * every money movement writes an append-only ledger entry
+
+Commit policy: create_payment_intent / refund_booking are called from request
+handlers and commit themselves. confirm_payment / fail_payment are called from
+the webhook handler, which owns the commit.
+
+Refund safety: Stripe refund calls are wrapped (a failure records a `failed`
+Refund row and never aborts the surrounding confirmation) and are idempotent
+(a payment already past `succeeded` is not refunded twice).
+"""
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy import func
+
+from app.core.config import settings
+from app.core.stripe_client import get_stripe
+from app.core.exceptions import NotFoundError, ForbiddenError, BadRequestError
+from app.modules.auth.dependencies import AuthContext
+from app.modules.booking.models import (
+ Booking, BookingStatus, PaymentStatus, BookingSlot, BookingStatusHistory,
+)
+from app.modules.booking.service import _booking_out
+from sqlalchemy.orm import joinedload, selectinload
+from app.modules.profile.models import Profile
+from app.modules.booking.state_machine import can_transition
+from app.modules.venue.models import Venue
+from app.modules.payment.models import (
+ Payment, Refund, LedgerEntry, PaymentAttemptStatus, RefundStatus,
+)
+from app.modules.payment.schemas import (
+ PaymentIntentResponse, PaymentResponse, RefundResponse, OwnerLedgerStatsResponse, LedgerEntryResponse
+)
+from app.modules.notification import service as notifications
+
+logger = logging.getLogger(__name__)
+
+ADVANCE = "advance"
+BALANCE = "balance"
+
+
+# --------------------------------------------------------------------------- #
+# Request-path: create a payment intent (advance or balance)
+# --------------------------------------------------------------------------- #
+def create_payment_intent(
+ db: Session, current_user_id, booking_id: str, payment_type: str = ADVANCE
+) -> PaymentIntentResponse:
+ booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first()
+ if not booking:
+ raise NotFoundError("Booking not found")
+ if str(booking.user_id) != str(current_user_id):
+ raise ForbiddenError("You do not own this booking")
+
+ venue = db.get(Venue, booking.venue_id)
+ if not venue:
+ raise NotFoundError("Venue not found")
+
+ if payment_type == ADVANCE:
+ if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending):
+ raise BadRequestError("Booking is not awaiting payment")
+ now = datetime.now(timezone.utc)
+ if booking.status == BookingStatus.owner_accepted:
+ if not booking.hold_expires_at or booking.hold_expires_at < now:
+ raise BadRequestError("Payment hold has expired")
+ elif booking.status == BookingStatus.payment_pending:
+ if not booking.payment_expires_at or booking.payment_expires_at < now:
+ raise BadRequestError("Payment hold has expired")
+ amount_paise = booking.advance_due_paise
+ if amount_paise <= 0:
+ raise BadRequestError("Booking has no advance amount due")
+ idempotency_key = f"booking-{booking.id}-advance"
+ elif payment_type == BALANCE:
+ if booking.status != BookingStatus.confirmed or booking.payment_status != PaymentStatus.advance_paid:
+ raise BadRequestError("Booking is not awaiting a balance payment")
+ amount_paise = booking.balance_due_paise
+ if amount_paise <= 0:
+ raise BadRequestError("Booking has no balance amount due")
+ idempotency_key = f"booking-{booking.id}-balance"
+ elif payment_type == "full":
+ if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending):
+ raise BadRequestError("Booking is not awaiting payment")
+ now = datetime.now(timezone.utc)
+ if booking.status == BookingStatus.owner_accepted:
+ if not booking.hold_expires_at or booking.hold_expires_at < now:
+ raise BadRequestError("Payment hold has expired")
+ elif booking.status == BookingStatus.payment_pending:
+ if not booking.payment_expires_at or booking.payment_expires_at < now:
+ raise BadRequestError("Payment hold has expired")
+ amount_paise = booking.quoted_price_paise
+ if amount_paise <= 0:
+ raise BadRequestError("Booking has no amount due")
+ idempotency_key = f"booking-{booking.id}-full"
+ else:
+ raise BadRequestError("Invalid payment type")
+
+ stripe = get_stripe()
+ intent = stripe.PaymentIntent.create(
+ amount=amount_paise,
+ currency=settings.stripe_currency,
+ metadata={
+ "booking_id": str(booking.id),
+ "user_id": str(booking.user_id),
+ "payment_type": payment_type,
+ },
+ idempotency_key=idempotency_key,
+ )
+
+ payment = Payment(
+ booking_id=booking.id,
+ amount_paise=amount_paise,
+ currency=settings.stripe_currency,
+ status=PaymentAttemptStatus.pending,
+ stripe_payment_intent_id=intent.id,
+ stripe_client_secret=intent.client_secret,
+ payment_type=payment_type,
+ )
+ db.add(payment)
+ if payment_type in (ADVANCE, "full"):
+ booking.stripe_advance_payment_intent_id = intent.id
+ booking.payment_status = PaymentStatus.unpaid
+ else:
+ booking.stripe_balance_payment_intent_id = intent.id
+ db.commit()
+ db.refresh(payment)
+
+ return PaymentIntentResponse(
+ payment_id=str(payment.id),
+ booking_id=str(booking.id),
+ client_secret=intent.client_secret,
+ amount_paise=amount_paise,
+ currency=payment.currency,
+ status=payment.status.value,
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Webhook-path: confirm / fail (caller commits)
+# --------------------------------------------------------------------------- #
+def confirm_payment(db: Session, payment_intent_id: str) -> None:
+ payment = (
+ db.query(Payment)
+ .filter(Payment.stripe_payment_intent_id == payment_intent_id)
+ .with_for_update()
+ .first()
+ )
+ if not payment:
+ logger.warning("confirm_payment: no payment for intent %s", payment_intent_id)
+ return
+ booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first()
+ if not booking:
+ logger.warning("confirm_payment: no booking for payment %s", payment.id)
+ return
+
+ if payment.payment_type == BALANCE:
+ confirm_balance_payment(db, payment, booking)
+ return
+
+ # ---- advance / full payment confirmation ----
+ # Idempotent: already confirmed
+ if payment.status == PaymentAttemptStatus.succeeded and booking.status == BookingStatus.confirmed:
+ return
+
+ # Booking left the payable state (hold expired / canceled) before the webhook
+ # arrived — the money is owed back.
+ if booking.status not in (BookingStatus.owner_accepted, BookingStatus.payment_pending):
+ logger.warning("confirm_payment: booking %s in %s, refunding stray payment", booking.id, booking.status)
+ _record_refund(db, payment, booking, payment.amount_paise, "booking_not_payable")
+ return
+
+ venue = db.get(Venue, booking.venue_id)
+
+ # Claim the slot(s). The GIST exclusion rejects a second blocking range that
+ # overlaps an existing one on the same venue -> the loser hits IntegrityError.
+ slots = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all()
+ for s in slots:
+ s.is_blocking = True
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ logger.info("confirm_payment: booking %s lost the slot race; conflict-canceling", booking.id)
+ _conflict_cancel_self_and_refund(db, payment_intent_id)
+ return
+
+ # Won (or no slots yet). Confirm.
+ if not can_transition(booking.status, BookingStatus.confirmed):
+ logger.error("confirm_payment: illegal transition %s -> confirmed", booking.status)
+ return
+
+ old_status = booking.status
+ payment.status = PaymentAttemptStatus.succeeded
+ booking.status = BookingStatus.confirmed
+ booking.confirmed_at = datetime.now(timezone.utc)
+ booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise
+
+ if payment.payment_type == "full":
+ booking.balance_due_paise = 0
+ booking.payment_status = PaymentStatus.fully_paid
+ else:
+ booking.payment_status = (
+ PaymentStatus.fully_paid if booking.balance_due_paise == 0 else PaymentStatus.advance_paid
+ )
+
+ if old_status == BookingStatus.payment_pending:
+ booking.auto_confirmed_at = datetime.now(timezone.utc)
+ booking.confirmed_by = "SYSTEM"
+ else:
+ booking.confirmed_by = "OWNER"
+
+ owner_id = venue.owner_id if venue else booking.user_id
+ db.add(LedgerEntry(
+ booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id,
+ user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise,
+ direction="credit", stripe_pi_ref=payment_intent_id,
+ ))
+
+ fee_pct = float(venue.platform_commission_pct) if venue else settings.platform_fee_pct
+ fee = booking.platform_fee_paise or round(payment.amount_paise * fee_pct / 100)
+ booking.platform_fee_paise = fee
+ if fee:
+ db.add(LedgerEntry(
+ booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id,
+ user_id=booking.user_id, entry_type="platform_fee", amount_paise=fee,
+ direction="debit", stripe_pi_ref=payment_intent_id,
+ ))
+
+ reason_str = "full_payment_succeeded" if payment.payment_type == "full" else "token_payment_succeeded"
+ db.add(BookingStatusHistory(
+ booking_id=booking.id, old_status=old_status,
+ new_status=BookingStatus.confirmed, reason=reason_str,
+ ))
+
+ # Knock out competitors for the same slot and refund any who already paid.
+ for competitor in _find_competing_bookings(db, booking):
+ _conflict_cancel(db, competitor, venue)
+
+ venue_name = venue.name if venue else "your venue"
+ # skip_email=True: the customer gets one combined "confirmed + invoice"
+ # email from app.modules.booking.invoice once the async job generates the
+ # PDF, instead of this immediate email plus a second one later. The
+ # in-app notification still fires immediately either way.
+ notifications.notify(db, booking.user_id, "payment_confirmed",
+ context={"venue_name": venue_name}, booking_id=booking.id,
+ skip_email=True)
+ if venue:
+ notifications.notify(db, venue.owner_id, "payment_confirmed",
+ context={"venue_name": venue_name}, booking_id=booking.id)
+
+ from app.modules.booking.invoice import enqueue as enqueue_invoice
+ enqueue_invoice(db, booking.id)
+
+
+def confirm_balance_payment(db: Session, payment: Payment, booking: Booking) -> None:
+ """Capture the remaining balance on an already-confirmed booking.
+
+ The slot is already reserved (claimed at advance confirmation), so there is
+ no slot/conflict work here — only the advance_paid -> fully_paid transition.
+ """
+ # Idempotent: already fully paid
+ if payment.status == PaymentAttemptStatus.succeeded and booking.payment_status == PaymentStatus.fully_paid:
+ return
+
+ if booking.status != BookingStatus.confirmed:
+ logger.warning("confirm_balance_payment: booking %s in %s, refunding stray balance", booking.id, booking.status)
+ _record_refund(db, payment, booking, payment.amount_paise, "balance_not_payable")
+ return
+
+ venue = db.get(Venue, booking.venue_id)
+ owner_id = venue.owner_id if venue else booking.user_id
+
+ payment.status = PaymentAttemptStatus.succeeded
+ booking.amount_paid_paise = (booking.amount_paid_paise or 0) + payment.amount_paise
+ booking.payment_status = PaymentStatus.fully_paid
+ booking.balance_overdue_at = None
+ booking.owner_action_deadline = None
+
+ db.add(LedgerEntry(
+ booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id,
+ user_id=booking.user_id, entry_type="charge", amount_paise=payment.amount_paise,
+ direction="credit", stripe_pi_ref=payment.stripe_payment_intent_id,
+ ))
+
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, booking.user_id, "balance_paid",
+ context={"venue_name": venue_name}, booking_id=booking.id)
+ if venue:
+ notifications.notify(db, venue.owner_id, "balance_paid",
+ context={"venue_name": venue_name}, booking_id=booking.id)
+
+
+def fail_payment(db: Session, payment_intent_id: str) -> None:
+ payment = (
+ db.query(Payment)
+ .filter(Payment.stripe_payment_intent_id == payment_intent_id)
+ .with_for_update()
+ .first()
+ )
+ if not payment:
+ return
+ payment.status = PaymentAttemptStatus.failed
+ # Booking stays in its current state; the hold-expiry / balance-overdue jobs
+ # reclaim it if no retry succeeds.
+
+
+def cancel_payment_intent(payment_intent_id: str | None) -> None:
+ """Cancel an uncaptured Stripe PaymentIntent (e.g. a pending advance whose
+ booking is being cancelled before payment succeeds).
+
+ Resilient like _record_refund: a Stripe failure (already-captured, already
+ -canceled, or network error) is logged and swallowed so it never aborts the
+ surrounding cancellation transaction.
+ """
+ if not payment_intent_id:
+ return
+ try:
+ get_stripe().PaymentIntent.cancel(payment_intent_id)
+ except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn
+ logger.exception("Stripe cancel failed for payment intent %s", payment_intent_id)
+
+
+# --------------------------------------------------------------------------- #
+# Request-path: owner / admin refund (full)
+# --------------------------------------------------------------------------- #
+def refund_booking(db: Session, booking_id: str, current_user: AuthContext, reason: str | None) -> RefundResponse:
+ booking = db.query(Booking).filter(Booking.id == booking_id).with_for_update().first()
+ if not booking:
+ raise NotFoundError("Booking not found")
+
+ venue = db.get(Venue, booking.venue_id)
+ is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id)
+ if not current_user.is_admin() and not is_venue_owner:
+ raise ForbiddenError("Only the venue owner or an admin can refund this booking")
+
+ payments = _succeeded_payments(db, booking.id)
+ if not payments:
+ raise BadRequestError("No captured payment to refund")
+
+ # Full refund: every captured payment (advance + balance) is returned.
+ refunded = 0
+ for p in payments:
+ refunded += _record_refund(db, p, booking, p.amount_paise, reason or "owner_cancellation")
+
+ if refunded > 0:
+ booking.payment_status = PaymentStatus.refunded
+ venue_name = venue.name if venue else "your venue"
+ notifications.notify(db, booking.user_id, "refund_issued",
+ context={"venue_name": venue_name, "amount_rupees": refunded // 100},
+ booking_id=booking.id)
+
+ if booking.status == BookingStatus.confirmed and can_transition(booking.status, BookingStatus.user_cancelled):
+ booking.status = BookingStatus.user_cancelled
+ booking.cancelled_at = datetime.now(timezone.utc)
+ db.add(BookingStatusHistory(
+ booking_id=booking.id, old_status=BookingStatus.confirmed,
+ new_status=BookingStatus.user_cancelled, reason=reason or "owner_cancellation",
+ ))
+ # release the slots so they can be rebooked
+ for s in db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).all():
+ s.is_blocking = False
+
+ db.commit()
+ return RefundResponse(
+ booking_id=str(booking.id), refunded_paise=refunded,
+ status="succeeded" if refunded > 0 else "failed",
+ )
+
+
+def refund_for_cancellation(db: Session, booking: Booking, amount_paise: int, reason: str) -> int:
+ """Refund up to `amount_paise` across a booking's captured payments.
+
+ Used by the booking cancellation flows so their computed policy refunds
+ (full / partial / goodwill) move real money and write ledger entries.
+ Does NOT commit and does NOT set booking.payment_status — the caller owns
+ both (the caller knows whether the result is a full or partial refund).
+ """
+ if amount_paise <= 0:
+ return 0
+ remaining = amount_paise
+ refunded = 0
+ for p in _succeeded_payments(db, booking.id):
+ if remaining <= 0:
+ break
+ take = min(remaining, p.amount_paise)
+ got = _record_refund(db, p, booking, take, reason)
+ refunded += got
+ remaining -= got
+ return refunded
+
+
+def list_payments_for_booking(db: Session, booking_id: str, current_user: AuthContext) -> list[PaymentResponse]:
+ booking = db.get(Booking, booking_id)
+ if not booking:
+ raise NotFoundError("Booking not found")
+ venue = db.get(Venue, booking.venue_id)
+ is_owner_of_booking = str(booking.user_id) == str(current_user.user_id)
+ is_venue_owner = current_user.is_owner() and venue and str(venue.owner_id) == str(current_user.user_id)
+ if not (is_owner_of_booking or is_venue_owner or current_user.is_admin()):
+ raise ForbiddenError("Not allowed to view these payments")
+ rows = db.query(Payment).filter(Payment.booking_id == booking_id).all()
+ return [
+ PaymentResponse(
+ id=str(p.id), booking_id=str(p.booking_id), amount_paise=p.amount_paise,
+ currency=p.currency, status=p.status.value,
+ stripe_payment_intent_id=p.stripe_payment_intent_id,
+ )
+ for p in rows
+ ]
+
+
+def get_owner_ledger_stats(db: Session, current_user: AuthContext) -> OwnerLedgerStatsResponse:
+ if not current_user.is_owner():
+ raise ForbiddenError("Must be a venue owner")
+
+ entries = db.query(
+ LedgerEntry.entry_type,
+ LedgerEntry.direction,
+ func.sum(LedgerEntry.amount_paise).label("total")
+ ).filter(
+ LedgerEntry.owner_id == current_user.user_id
+ ).group_by(
+ LedgerEntry.entry_type,
+ LedgerEntry.direction
+ ).all()
+
+ gross_volume = 0
+ platform_fees = 0
+ refunds_issued = 0
+ payouts_completed = 0
+
+ for entry_type, direction, total in entries:
+ val = int(total or 0)
+ if entry_type == "charge" and direction == "credit":
+ gross_volume += val
+ elif entry_type == "platform_fee" and direction == "debit":
+ platform_fees += val
+ elif entry_type == "refund" and direction == "debit":
+ refunds_issued += val
+ elif entry_type == "payout" and direction == "debit":
+ payouts_completed += val
+
+ net_revenue = gross_volume - platform_fees - refunds_issued
+ available_balance = net_revenue - payouts_completed
+
+ return OwnerLedgerStatsResponse(
+ gross_volume_paise=gross_volume,
+ platform_fees_paise=platform_fees,
+ refunds_issued_paise=refunds_issued,
+ net_revenue_paise=net_revenue,
+ payouts_completed_paise=payouts_completed,
+ available_balance_paise=available_balance
+ )
+
+
+def list_owner_ledger_entries(db: Session, current_user: AuthContext, entry_type: str | None = None) -> list[LedgerEntryResponse]:
+ if not current_user.is_owner():
+ raise ForbiddenError("Must be a venue owner")
+
+ query = (
+ db.query(LedgerEntry, Venue, Profile)
+ .outerjoin(Venue, LedgerEntry.venue_id == Venue.id)
+ .outerjoin(Profile, LedgerEntry.user_id == Profile.id)
+ .filter(LedgerEntry.owner_id == current_user.user_id)
+ )
+
+ if entry_type and entry_type != "all":
+ query = query.filter(LedgerEntry.entry_type == entry_type)
+
+ query = query.order_by(LedgerEntry.created_at.desc())
+ results = query.all()
+
+ responses = []
+ for ledger, venue, profile in results:
+ responses.append(
+ LedgerEntryResponse(
+ id=str(ledger.id),
+ booking_id=str(ledger.booking_id),
+ venue_id=str(ledger.venue_id),
+ venue_name=venue.name if venue else None,
+ user_full_name=profile.full_name if profile else None,
+ entry_type=ledger.entry_type,
+ amount_paise=ledger.amount_paise,
+ direction=ledger.direction,
+ stripe_pi_ref=ledger.stripe_pi_ref,
+ created_at=ledger.created_at.isoformat()
+ )
+ )
+ return responses
+
+
+# --------------------------------------------------------------------------- #
+# Helpers
+# --------------------------------------------------------------------------- #
+def _succeeded_payments(db: Session, booking_id) -> list[Payment]:
+ return (
+ db.query(Payment)
+ .filter(Payment.booking_id == booking_id, Payment.status == PaymentAttemptStatus.succeeded)
+ .order_by(Payment.created_at.asc())
+ .all()
+ )
+
+
+def _record_refund(db: Session, payment: Payment, booking: Booking, amount_paise: int, reason: str) -> int:
+ """Issue a Stripe refund and record it (refund row + ledger debit).
+
+ Resilient: a Stripe failure records a `failed` Refund row and returns 0
+ rather than raising (so a competitor-refund failure never rolls back the
+ winner's confirmation). Idempotent: a payment that is not in `succeeded`
+ state is skipped (prevents double refunds). Does NOT send notifications —
+ the caller decides which message to send.
+ """
+ if amount_paise <= 0:
+ return 0
+ if payment.status != PaymentAttemptStatus.succeeded:
+ logger.info("refund skipped: payment %s not refundable (status=%s)", payment.id, payment.status)
+ return 0
+
+ stripe = get_stripe()
+ try:
+ refund = stripe.Refund.create(
+ payment_intent=payment.stripe_payment_intent_id,
+ amount=amount_paise,
+ metadata={"booking_id": str(booking.id), "reason": reason},
+ )
+ except Exception: # noqa: BLE001 — Stripe/network failure must not abort the txn
+ logger.exception("Stripe refund failed for payment %s (booking %s)", payment.id, booking.id)
+ db.add(Refund(
+ payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise,
+ reason=reason, status=RefundStatus.failed, stripe_refund_id=None,
+ ))
+ return 0
+
+ db.add(Refund(
+ payment_id=payment.id, booking_id=booking.id, amount_paise=amount_paise,
+ reason=reason, status=RefundStatus.succeeded, stripe_refund_id=refund.id,
+ ))
+ # Mark the attempt refunded only when the whole captured amount is returned.
+ if amount_paise >= payment.amount_paise:
+ payment.status = PaymentAttemptStatus.refunded
+ booking.refund_amount_paise = (booking.refund_amount_paise or 0) + amount_paise
+
+ venue = db.get(Venue, booking.venue_id)
+ owner_id = venue.owner_id if venue else booking.user_id
+ db.add(LedgerEntry(
+ booking_id=booking.id, venue_id=booking.venue_id, owner_id=owner_id,
+ user_id=booking.user_id, entry_type="refund", amount_paise=amount_paise,
+ direction="debit", stripe_pi_ref=payment.stripe_payment_intent_id,
+ ))
+ return amount_paise
+
+
+def _find_competing_bookings(db: Session, booking: Booking) -> list[Booking]:
+ """Other active bookings contending for the same slot.
+
+ Overlap rule: same venue, still requested/owner_accepted, whose effective slot
+ range intersects this booking's effective slot range.
+ """
+ slot = db.query(BookingSlot).filter(BookingSlot.booking_id == booking.id).first()
+ if slot is None:
+ return []
+ return (
+ db.query(Booking)
+ .join(BookingSlot, BookingSlot.booking_id == Booking.id)
+ .filter(
+ Booking.id != booking.id,
+ Booking.venue_id == booking.venue_id,
+ Booking.status.in_([BookingStatus.requested, BookingStatus.owner_accepted, BookingStatus.payment_pending]),
+ BookingSlot.deleted_at.is_(None),
+ BookingSlot.effective_starts_at < slot.effective_ends_at,
+ BookingSlot.effective_ends_at > slot.effective_starts_at,
+ )
+ .with_for_update()
+ .all()
+ )
+
+
+def _conflict_cancel(db: Session, competitor: Booking, venue: Venue | None) -> None:
+ old = competitor.status
+ if not can_transition(old, BookingStatus.conflict_cancelled):
+ logger.warning("skip conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, competitor.id)
+ return
+ competitor.status = BookingStatus.conflict_cancelled
+ competitor.cancelled_at = datetime.now(timezone.utc)
+ db.add(BookingStatusHistory(
+ booking_id=competitor.id, old_status=old, new_status=BookingStatus.conflict_cancelled,
+ reason="slot_confirmed_by_another",
+ ))
+ # Refund any captured payment; the conflict_canceled notice (below) already
+ # tells the user their money was refunded, so no separate refund_issued.
+ for paid in _succeeded_payments(db, competitor.id):
+ _record_refund(db, paid, competitor, paid.amount_paise, "conflict_canceled")
+ venue_name = venue.name if venue else "the venue"
+ notifications.notify(db, competitor.user_id, "conflict_canceled",
+ context={"venue_name": venue_name}, booking_id=competitor.id)
+
+
+def _conflict_cancel_self_and_refund(db: Session, payment_intent_id: str) -> None:
+ """The current booking lost the slot race — cancel it and refund this payment."""
+ payment = (
+ db.query(Payment)
+ .filter(Payment.stripe_payment_intent_id == payment_intent_id)
+ .with_for_update()
+ .first()
+ )
+ if not payment:
+ return
+ booking = db.query(Booking).filter(Booking.id == payment.booking_id).with_for_update().first()
+ if not booking:
+ return
+ venue = db.get(Venue, booking.venue_id)
+ old = booking.status
+ if not can_transition(old, BookingStatus.conflict_cancelled):
+ logger.warning("skip self conflict-cancel: illegal %s -> conflict_cancelled for booking %s", old, booking.id)
+ return
+ booking.status = BookingStatus.conflict_cancelled
+ booking.cancelled_at = datetime.now(timezone.utc)
+ db.add(BookingStatusHistory(
+ booking_id=booking.id, old_status=old, new_status=BookingStatus.conflict_cancelled,
+ reason="lost_slot_race",
+ ))
+ # This payment just succeeded but hasn't been marked succeeded yet — do so
+ # so the refund guard recognises it as refundable.
+ payment.status = PaymentAttemptStatus.succeeded
+ _record_refund(db, payment, booking, payment.amount_paise, "lost_slot_race")
+ venue_name = venue.name if venue else "the venue"
+ notifications.notify(db, booking.user_id, "conflict_canceled",
+ context={"venue_name": venue_name}, booking_id=booking.id)
diff --git a/apps/api/app/modules/payment/webhooks.py b/apps/api/app/modules/payment/webhooks.py
new file mode 100644
index 000000000..7691e4e95
--- /dev/null
+++ b/apps/api/app/modules/payment/webhooks.py
@@ -0,0 +1,104 @@
+"""Stripe webhook entrypoint.
+
+Flow: verify signature -> record the event in stripe_events (idempotency guard,
+duplicate = no-op) -> dispatch -> stamp processed_at / processing_error.
+"""
+import json
+import logging
+from datetime import datetime, timezone
+
+from fastapi import Request, HTTPException
+from sqlalchemy.exc import IntegrityError
+
+from app.core.config import settings
+from app.core.database import SessionLocal
+from app.core.stripe_client import get_stripe
+from app.modules.payment.models import StripeEvent
+from app.modules.payment import service
+
+logger = logging.getLogger(__name__)
+
+
+async def handle(request: Request):
+ payload = await request.body()
+ sig = request.headers.get("stripe-signature")
+ stripe = get_stripe()
+
+ if settings.stripe_webhook_secret:
+ try:
+ event = stripe.Webhook.construct_event(payload, sig, settings.stripe_webhook_secret)
+ except Exception as e: # signature / parse failure
+ logger.warning("Invalid Stripe webhook: %s", e)
+ raise HTTPException(status_code=400, detail="Invalid signature")
+ else:
+ # dev fallback when no signing secret is configured
+ logger.warning("STRIPE_WEBHOOK_SECRET unset — skipping signature verification")
+ event = json.loads(payload)
+
+ event_id = event["id"]
+ event_type = event["type"]
+
+ db = SessionLocal()
+ try:
+ # Idempotency guard keyed on the Stripe event id. We record the event
+ # first, but treat it as a true duplicate ONLY once it has been
+ # *successfully* processed (processed_at set). An event that was seen but
+ # failed to process (processing_error, processed_at NULL) is re-dispatched
+ # when Stripe retries — so a transient failure can never strand a paid
+ # booking in an unconfirmed state.
+ stored = db.get(StripeEvent, event_id)
+ if stored is None:
+ stored = StripeEvent(id=event_id, type=event_type, raw_payload=_json_safe(event))
+ db.add(stored)
+ try:
+ db.commit()
+ except IntegrityError:
+ # Concurrent delivery inserted it first — reload and continue.
+ db.rollback()
+ stored = db.get(StripeEvent, event_id)
+
+ if stored is None:
+ # Extremely unlikely row contention; ask Stripe to retry.
+ raise HTTPException(status_code=409, detail="Event contention, retry")
+ if stored.processed_at is not None:
+ logger.info("Duplicate Stripe event %s ignored (already processed)", event_id)
+ return {"status": "duplicate"}
+
+ try:
+ _dispatch(db, event)
+ stored = db.get(StripeEvent, event_id)
+ stored.processed_at = datetime.now(timezone.utc)
+ stored.processing_error = None
+ db.commit()
+ except HTTPException:
+ raise
+ except Exception as e:
+ db.rollback()
+ stored = db.get(StripeEvent, event_id)
+ if stored:
+ stored.processing_error = str(e)
+ db.commit()
+ logger.exception("Error processing Stripe event %s", event_id)
+ raise HTTPException(status_code=500, detail="Processing error")
+
+ return {"status": "ok"}
+ finally:
+ db.close()
+
+
+def _dispatch(db, event) -> None:
+ event_type = event["type"]
+ obj = event["data"]["object"]
+ if event_type == "payment_intent.succeeded":
+ service.confirm_payment(db, obj["id"])
+ elif event_type == "payment_intent.payment_failed":
+ service.fail_payment(db, obj["id"])
+ else:
+ logger.info("Unhandled Stripe event type %s", event_type)
+
+
+def _json_safe(event) -> dict | None:
+ try:
+ return json.loads(json.dumps(event, default=str))
+ except (TypeError, ValueError):
+ return None
diff --git a/apps/api/app/modules/profile/models.py b/apps/api/app/modules/profile/models.py
new file mode 100644
index 000000000..1b316589e
--- /dev/null
+++ b/apps/api/app/modules/profile/models.py
@@ -0,0 +1,56 @@
+import enum
+import uuid
+from datetime import datetime
+from sqlalchemy import String, Enum, DateTime, UniqueConstraint, func, ForeignKey
+from sqlalchemy.orm import mapped_column, Mapped, relationship
+from sqlalchemy.dialects.postgresql import UUID
+from app.core.database import Base
+
+
+class ProfileStatus(str, enum.Enum):
+ active = "active"
+ suspended = "suspended"
+ pending = "pending"
+ rejected = "rejected"
+
+
+class UserRole(str, enum.Enum):
+ customer = "customer"
+ venue_owner = "venue_owner"
+ super_admin = "super_admin"
+
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
+ email: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
+ full_name: Mapped[str | None] = mapped_column(String, nullable=True)
+ phone: Mapped[str | None] = mapped_column(String, nullable=True)
+ avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
+ status: Mapped[ProfileStatus] = mapped_column(
+ Enum(ProfileStatus, name="profile_status", create_constraint=False),
+ nullable=False,
+ default=ProfileStatus.active,
+ )
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), onupdate=func.now())
+
+ bookings = relationship(
+ "Booking",
+ back_populates="user",
+ )
+
+
+class UserRoleAssignment(Base):
+ __tablename__ = "user_roles"
+ __table_args__ = (UniqueConstraint("user_id", "role", name="uq_user_roles_user_role"),)
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
+ role: Mapped[UserRole] = mapped_column(
+ Enum(UserRole, name="user_role", create_constraint=False),
+ nullable=False,
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
diff --git a/apps/api/app/modules/profile/routes.py b/apps/api/app/modules/profile/routes.py
new file mode 100644
index 000000000..c70240e46
--- /dev/null
+++ b/apps/api/app/modules/profile/routes.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter
+from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest
+from app.modules.auth.dependencies import get_current_user
+from fastapi import Depends
+from app.modules.profile import service
+
+router = APIRouter()
+
+
+@router.get("/me", response_model=ProfileResponse)
+def get_profile(user=Depends(get_current_user)):
+ return service.get_profile(user["sub"])
+
+
+@router.patch("/me", response_model=ProfileResponse)
+def update_profile(body: UpdateProfileRequest, user=Depends(get_current_user)):
+ return service.update_profile(user["sub"], body)
diff --git a/apps/api/app/modules/profile/schemas.py b/apps/api/app/modules/profile/schemas.py
new file mode 100644
index 000000000..ed1868e16
--- /dev/null
+++ b/apps/api/app/modules/profile/schemas.py
@@ -0,0 +1,13 @@
+from pydantic import BaseModel, EmailStr
+from typing import Optional
+
+
+class ProfileResponse(BaseModel):
+ id: str
+ email: EmailStr
+ full_name: str
+ role: str
+
+
+class UpdateProfileRequest(BaseModel):
+ full_name: Optional[str] = None
diff --git a/apps/api/app/modules/profile/service.py b/apps/api/app/modules/profile/service.py
new file mode 100644
index 000000000..be131d0fc
--- /dev/null
+++ b/apps/api/app/modules/profile/service.py
@@ -0,0 +1,9 @@
+from app.modules.profile.schemas import ProfileResponse, UpdateProfileRequest
+
+
+def get_profile(user_id: str) -> ProfileResponse:
+ raise NotImplementedError
+
+
+def update_profile(user_id: str, body: UpdateProfileRequest) -> ProfileResponse:
+ raise NotImplementedError
diff --git a/apps/api/app/modules/review/__init__.py b/apps/api/app/modules/review/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/api/app/modules/review/models.py b/apps/api/app/modules/review/models.py
new file mode 100644
index 000000000..4cd3745e9
--- /dev/null
+++ b/apps/api/app/modules/review/models.py
@@ -0,0 +1,82 @@
+import uuid
+from datetime import datetime
+from sqlalchemy import ForeignKey, Boolean, String, Integer, Text, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from typing import TYPE_CHECKING
+
+from app.core.database import Base
+
+if TYPE_CHECKING:
+ from app.modules.venue.models import Venue
+ from app.modules.booking.models import Booking
+ from app.modules.profile.models import Profile
+
+
+class VenueReview(Base):
+ __tablename__ = "venue_reviews"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ venue_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("venues.id"),
+ nullable=False,
+ index=True,
+ )
+
+ booking_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("bookings.id"),
+ nullable=False,
+ unique=True,
+ )
+
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id"),
+ nullable=False,
+ index=True,
+ )
+
+ rating: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ title: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ comment: Mapped[str] = mapped_column(Text, nullable=False)
+
+ is_hidden: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
+
+ hidden_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ hidden_by: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("profiles.id"),
+ nullable=True,
+ )
+
+ hidden_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, index=True
+ )
+
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime,
+ default=datetime.utcnow,
+ onupdate=datetime.utcnow,
+ )
+
+ deleted_at: Mapped[datetime | None] = mapped_column(
+ DateTime, nullable=True, index=True
+ )
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="reviews")
+ booking: Mapped["Booking"] = relationship()
+ author: Mapped["Profile"] = relationship(foreign_keys=[user_id])
+ hidden_by_user: Mapped["Profile | None"] = relationship(foreign_keys=[hidden_by])
diff --git a/apps/api/app/modules/review/routes.py b/apps/api/app/modules/review/routes.py
new file mode 100644
index 000000000..363434d1e
--- /dev/null
+++ b/apps/api/app/modules/review/routes.py
@@ -0,0 +1,179 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import AuthContext, require_auth, require_admin
+from app.modules.review.service import ReviewService
+from app.modules.review.schemas import (
+ ReviewCreate,
+ ReviewUpdate,
+ ReviewResponse,
+ ReviewListResponse,
+ ReviewSummaryResponse,
+ EligibleBookingsResponse,
+ AdminReviewActionRequest,
+)
+
+router = APIRouter()
+
+
+# ────────────────────────────────────────────────────────────────
+# CUSTOMER ENDPOINTS
+# ────────────────────────────────────────────────────────────────
+
+@router.post(
+ "/venues/{venue_id}/reviews", response_model=ReviewResponse, status_code=201
+)
+def create_review(
+ venue_id: UUID,
+ body: ReviewCreate,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ """Create a new review for a venue (requires completed booking)."""
+ return ReviewService.create_review(db, auth.user_id, venue_id, body)
+
+
+@router.get("/venues/{venue_id}/reviews", response_model=ReviewListResponse)
+def list_venue_reviews(
+ venue_id: UUID,
+ page: int = Query(1, ge=1),
+ per_page: int = Query(10, ge=1, le=100),
+ db: Session = Depends(get_db),
+):
+ """List public reviews for a venue (paginated)."""
+ return ReviewService.list_venue_reviews(db, venue_id, page, per_page)
+
+
+@router.get("/reviews/{review_id}", response_model=ReviewResponse)
+def get_review(
+ review_id: UUID,
+ db: Session = Depends(get_db),
+):
+ """Get a single public review."""
+ return ReviewService.get_review(db, review_id)
+
+
+@router.patch("/reviews/{review_id}", response_model=ReviewResponse)
+def update_review(
+ review_id: UUID,
+ body: ReviewUpdate,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ """Update your own review (rating, title, comment only)."""
+ return ReviewService.update_review(db, auth.user_id, review_id, body)
+
+
+@router.delete("/reviews/{review_id}", status_code=204)
+def delete_review(
+ review_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ """Soft delete your own review."""
+ ReviewService.delete_review(db, auth.user_id, review_id)
+
+
+@router.get(
+ "/venues/{venue_id}/reviews/eligible-bookings",
+ response_model=EligibleBookingsResponse,
+)
+def get_eligible_booking_ids(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ """Get booking IDs eligible for review at a venue."""
+ return ReviewService.get_eligible_booking_ids(db, auth.user_id, venue_id)
+
+
+# ────────────────────────────────────────────────────────────────
+# ADMIN ENDPOINTS
+# ────────────────────────────────────────────────────────────────
+
+@router.get("/admin/reviews", response_model=ReviewListResponse)
+def list_all_reviews(
+ page: int = Query(1, ge=1),
+ per_page: int = Query(10, ge=1, le=100),
+ venue_id: UUID | None = Query(None),
+ user_id: UUID | None = Query(None),
+ rating: int | None = Query(None, ge=1, le=5),
+ is_hidden: bool | None = Query(None),
+ include_deleted: bool = Query(False),
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ """List all reviews with filters (admin only)."""
+ return ReviewService.list_all_reviews(
+ db,
+ page=page,
+ per_page=per_page,
+ venue_id=venue_id,
+ user_id=user_id,
+ rating=rating,
+ is_hidden=is_hidden,
+ include_deleted=include_deleted,
+ )
+
+
+@router.get("/admin/reviews/{review_id}", response_model=ReviewResponse)
+def get_admin_review(
+ review_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ """Get a review (admin can see hidden/deleted reviews)."""
+ from app.modules.review.models import VenueReview
+ from app.core.exceptions import APIException
+
+ review = db.query(VenueReview).filter(VenueReview.id == review_id).first()
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ return ReviewService._to_response(db, review)
+
+
+@router.patch("/admin/reviews/{review_id}/hide", response_model=ReviewResponse)
+def hide_review(
+ review_id: UUID,
+ body: AdminReviewActionRequest,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ """Hide a review (admin only)."""
+ return ReviewService.hide_review(db, auth.user_id, review_id, body.hidden_reason)
+
+
+@router.patch("/admin/reviews/{review_id}/restore", response_model=ReviewResponse)
+def restore_review(
+ review_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ """Restore a hidden review (admin only)."""
+ return ReviewService.restore_review(db, auth.user_id, review_id)
+
+
+@router.delete("/admin/reviews/{review_id}", status_code=204)
+def delete_review_admin(
+ review_id: UUID,
+ auth: AuthContext = Depends(require_admin),
+ db: Session = Depends(get_db),
+):
+ """Hard delete a review (admin only, permanent)."""
+ ReviewService.delete_review_admin(db, auth.user_id, review_id)
+
+
+# ────────────────────────────────────────────────────────────────
+# SUMMARY ENDPOINT
+# ────────────────────────────────────────────────────────────────
+
+@router.get("/venues/{venue_id}/reviews/summary", response_model=ReviewSummaryResponse)
+def get_rating_summary(
+ venue_id: UUID,
+ db: Session = Depends(get_db),
+):
+ """Get rating summary (average, count, distribution) for a venue."""
+ return ReviewService.get_rating_summary(db, venue_id)
\ No newline at end of file
diff --git a/apps/api/app/modules/review/schemas.py b/apps/api/app/modules/review/schemas.py
new file mode 100644
index 000000000..3defaa4ec
--- /dev/null
+++ b/apps/api/app/modules/review/schemas.py
@@ -0,0 +1,82 @@
+from datetime import datetime
+from uuid import UUID
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+class ReviewCreate(BaseModel):
+ """Input schema for creating a review."""
+
+ rating: int = Field(ge=1, le=5, description="Rating from 1 to 5")
+ title: Optional[str] = Field(
+ None, max_length=255, description="Optional review title"
+ )
+ comment: str = Field(
+ min_length=1, max_length=5000, description="Required review comment"
+ )
+
+
+class ReviewUpdate(BaseModel):
+ """Input schema for updating a review."""
+
+ rating: Optional[int] = Field(None, ge=1, le=5)
+ title: Optional[str] = Field(None, max_length=255)
+ comment: Optional[str] = Field(None, min_length=1, max_length=5000)
+
+
+class ReviewResponse(BaseModel):
+ """Single review response."""
+
+ id: UUID
+ venue_id: UUID
+ booking_id: UUID
+ user_id: UUID
+ rating: int
+ title: Optional[str]
+ comment: str
+ is_hidden: bool
+ created_at: datetime
+ updated_at: datetime
+
+ # Author info (denormalized)
+ user_name: Optional[str] = None
+ user_email: Optional[str] = None
+
+ # Hide info
+ hidden_reason: Optional[str] = None
+ hidden_by: Optional[UUID] = None
+ hidden_at: Optional[datetime] = None
+
+ class Config:
+ from_attributes = True
+
+
+class ReviewListResponse(BaseModel):
+ """Paginated list of reviews."""
+
+ items: list[ReviewResponse]
+ total: int
+ page: int
+ per_page: int
+
+
+class ReviewSummaryResponse(BaseModel):
+ """Rating statistics for a venue."""
+
+ average_rating: float
+ total_reviews: int
+ rating_distribution: dict[str, int] # Keys are "1" through "5"
+
+
+class EligibleBookingsResponse(BaseModel):
+ """List of booking IDs eligible for review at a venue."""
+
+ booking_ids: list[UUID]
+
+
+class AdminReviewActionRequest(BaseModel):
+ """Input for admin actions (hide/restore)."""
+
+ hidden_reason: Optional[str] = Field(
+ None, max_length=255, description="Reason for hiding review"
+ )
\ No newline at end of file
diff --git a/apps/api/app/modules/review/service.py b/apps/api/app/modules/review/service.py
new file mode 100644
index 000000000..cdb13090f
--- /dev/null
+++ b/apps/api/app/modules/review/service.py
@@ -0,0 +1,417 @@
+from datetime import datetime
+from uuid import UUID
+from sqlalchemy.orm import Session
+from sqlalchemy import desc
+from app.core.exceptions import APIException
+from app.modules.review.models import VenueReview
+from app.modules.review.schemas import (
+ ReviewCreate,
+ ReviewUpdate,
+ ReviewResponse,
+ ReviewListResponse,
+ ReviewSummaryResponse,
+ EligibleBookingsResponse,
+)
+from app.modules.booking.models import Booking, BookingStatus
+
+
+class ReviewService:
+ """Service layer for review operations."""
+
+ # ────────────────────────────────────────────────────────────────
+ # CUSTOMER METHODS
+ # ────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def create_review(
+ db: Session,
+ user_id: UUID,
+ venue_id: UUID,
+ payload: ReviewCreate,
+ ) -> ReviewResponse:
+ """
+ Create a new review for a venue.
+
+ Validates:
+ - Booking exists for user and venue
+ - Booking status is "completed"
+ - No existing review for the booking
+ """
+ # Get the user's completed booking for this venue
+ booking = (
+ db.query(Booking)
+ .filter(
+ Booking.user_id == user_id,
+ Booking.venue_id == venue_id,
+ Booking.status == BookingStatus.completed,
+ )
+ .first()
+ )
+
+ if not booking:
+ raise APIException(
+ status_code=422,
+ detail="You don't have a completed booking for this venue.",
+ )
+
+ # Check if booking already has a review
+ existing_review = (
+ db.query(VenueReview).filter(VenueReview.booking_id == booking.id).first()
+ )
+ if existing_review:
+ raise APIException(
+ status_code=422,
+ detail="You have already reviewed this booking.",
+ )
+
+ # Create review
+ review = VenueReview(
+ venue_id=venue_id,
+ booking_id=booking.id,
+ user_id=user_id,
+ rating=payload.rating,
+ title=payload.title,
+ comment=payload.comment,
+ )
+
+ db.add(review)
+ db.flush()
+
+ return ReviewService._to_response(db, review)
+
+ @staticmethod
+ def update_review(
+ db: Session,
+ user_id: UUID,
+ review_id: UUID,
+ payload: ReviewUpdate,
+ ) -> ReviewResponse:
+ """
+ Update a review. Only the owner can update.
+
+ Allowed fields: rating, title, comment
+ """
+ review = (
+ db.query(VenueReview)
+ .filter(VenueReview.id == review_id, VenueReview.deleted_at.is_(None))
+ .first()
+ )
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ if review.user_id != user_id:
+ raise APIException(
+ status_code=403, detail="You can only edit your own reviews."
+ )
+
+ # Update allowed fields
+ if payload.rating is not None:
+ review.rating = payload.rating
+ if payload.title is not None:
+ review.title = payload.title
+ if payload.comment is not None:
+ review.comment = payload.comment
+
+ review.updated_at = datetime.utcnow()
+ db.flush()
+
+ return ReviewService._to_response(db, review)
+
+ @staticmethod
+ def delete_review(
+ db: Session,
+ user_id: UUID,
+ review_id: UUID,
+ ) -> None:
+ """
+ Soft delete a review. Only the owner can delete.
+ """
+ review = (
+ db.query(VenueReview)
+ .filter(VenueReview.id == review_id, VenueReview.deleted_at.is_(None))
+ .first()
+ )
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ if review.user_id != user_id:
+ raise APIException(
+ status_code=403, detail="You can only delete your own reviews."
+ )
+
+ review.deleted_at = datetime.utcnow()
+ db.flush()
+
+ @staticmethod
+ def get_review(db: Session, review_id: UUID) -> ReviewResponse:
+ """
+ Get a single review by ID. Excludes deleted and hidden reviews.
+ """
+ review = (
+ db.query(VenueReview)
+ .filter(
+ VenueReview.id == review_id,
+ VenueReview.deleted_at.is_(None),
+ VenueReview.is_hidden.is_(False),
+ )
+ .first()
+ )
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ return ReviewService._to_response(db, review)
+
+ @staticmethod
+ def list_venue_reviews(
+ db: Session,
+ venue_id: UUID,
+ page: int = 1,
+ per_page: int = 10,
+ ) -> ReviewListResponse:
+ """
+ List public reviews for a venue (paginated).
+ Excludes deleted and hidden reviews.
+ """
+ query = (
+ db.query(VenueReview)
+ .filter(
+ VenueReview.venue_id == venue_id,
+ VenueReview.deleted_at.is_(None),
+ VenueReview.is_hidden.is_(False),
+ )
+ .order_by(desc(VenueReview.created_at))
+ )
+
+ total = query.count()
+ offset = (page - 1) * per_page
+ reviews = query.offset(offset).limit(per_page).all()
+
+ return ReviewListResponse(
+ items=[ReviewService._to_response(db, r) for r in reviews],
+ total=total,
+ page=page,
+ per_page=per_page,
+ )
+
+ @staticmethod
+ def get_eligible_booking_ids(
+ db: Session,
+ user_id: UUID,
+ venue_id: UUID,
+ ) -> EligibleBookingsResponse:
+ """
+ Get booking IDs eligible for review at a venue.
+
+ Returns completed bookings that:
+ - Belong to the user
+ - Are for the specified venue
+ - Do not already have a review
+ """
+ # Get completed bookings for user at venue that don't have reviews yet
+ eligible_bookings = (
+ db.query(Booking.id)
+ .filter(
+ Booking.user_id == user_id,
+ Booking.venue_id == venue_id,
+ Booking.status == BookingStatus.completed,
+ )
+ .all()
+ )
+
+ # Filter out bookings that already have reviews
+ booking_ids = []
+ for (booking_id,) in eligible_bookings:
+ has_review = db.query(VenueReview).filter(
+ VenueReview.booking_id == booking_id
+ ).first()
+ if not has_review:
+ booking_ids.append(str(booking_id))
+
+ return EligibleBookingsResponse(booking_ids=booking_ids)
+
+ # ────────────────────────────────────────────────────────────────
+ # ADMIN METHODS
+ # ────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def list_all_reviews(
+ db: Session,
+ page: int = 1,
+ per_page: int = 10,
+ venue_id: UUID | None = None,
+ user_id: UUID | None = None,
+ rating: int | None = None,
+ is_hidden: bool | None = None,
+ include_deleted: bool = False,
+ ) -> ReviewListResponse:
+ """
+ List all reviews with optional filters (admin only).
+ """
+ query = db.query(VenueReview)
+
+ if venue_id:
+ query = query.filter(VenueReview.venue_id == venue_id)
+ if user_id:
+ query = query.filter(VenueReview.user_id == user_id)
+ if rating is not None:
+ query = query.filter(VenueReview.rating == rating)
+ if is_hidden is not None:
+ query = query.filter(VenueReview.is_hidden == is_hidden)
+ if not include_deleted:
+ query = query.filter(VenueReview.deleted_at.is_(None))
+
+ total = query.count()
+ offset = (page - 1) * per_page
+ reviews = (
+ query.order_by(desc(VenueReview.created_at))
+ .offset(offset)
+ .limit(per_page)
+ .all()
+ )
+
+ return ReviewListResponse(
+ items=[ReviewService._to_response(db, r) for r in reviews],
+ total=total,
+ page=page,
+ per_page=per_page,
+ )
+
+ @staticmethod
+ def hide_review(
+ db: Session,
+ admin_id: UUID,
+ review_id: UUID,
+ reason: str | None = None,
+ ) -> ReviewResponse:
+ """
+ Hide a review (admin action).
+ """
+ review = db.query(VenueReview).filter(VenueReview.id == review_id).first()
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ review.is_hidden = True
+ review.hidden_reason = reason
+ review.hidden_by = admin_id
+ review.hidden_at = datetime.utcnow()
+ db.flush()
+
+ return ReviewService._to_response(db, review)
+
+ @staticmethod
+ def restore_review(
+ db: Session,
+ admin_id: UUID,
+ review_id: UUID,
+ ) -> ReviewResponse:
+ """
+ Restore a hidden review (admin action).
+ """
+ review = db.query(VenueReview).filter(VenueReview.id == review_id).first()
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ if not review.is_hidden:
+ raise APIException(status_code=422, detail="Review is not hidden.")
+
+ review.is_hidden = False
+ review.hidden_reason = None
+ review.hidden_by = None
+ review.hidden_at = None
+ db.flush()
+
+ return ReviewService._to_response(db, review)
+
+ @staticmethod
+ def delete_review_admin(
+ db: Session,
+ admin_id: UUID,
+ review_id: UUID,
+ ) -> None:
+ """
+ Hard delete a review (admin only). Permanent.
+ """
+ review = db.query(VenueReview).filter(VenueReview.id == review_id).first()
+
+ if not review:
+ raise APIException(status_code=404, detail="Review not found.")
+
+ db.delete(review)
+ db.flush()
+
+ # ────────────────────────────────────────────────────────────────
+ # STATISTICS
+ # ────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def get_rating_summary(db: Session, venue_id: UUID) -> ReviewSummaryResponse:
+ """
+ Get rating statistics for a venue.
+ Only includes public (non-hidden, non-deleted) reviews.
+ """
+ reviews = (
+ db.query(VenueReview)
+ .filter(
+ VenueReview.venue_id == venue_id,
+ VenueReview.deleted_at.is_(None),
+ VenueReview.is_hidden.is_(False),
+ )
+ .all()
+ )
+
+ if not reviews:
+ return ReviewSummaryResponse(
+ average_rating=0.0,
+ total_reviews=0,
+ rating_distribution={"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
+ )
+
+ # Calculate average
+ total_rating = sum(r.rating for r in reviews)
+ average_rating = total_rating / len(reviews)
+
+ # Calculate distribution
+ distribution = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
+ for review in reviews:
+ distribution[str(review.rating)] += 1
+
+ return ReviewSummaryResponse(
+ average_rating=round(average_rating, 2),
+ total_reviews=len(reviews),
+ rating_distribution=distribution,
+ )
+
+ # ────────────────────────────────────────────────────────────────
+ # HELPERS
+ # ────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def _to_response(db: Session, review: VenueReview) -> ReviewResponse:
+ """
+ Convert ORM model to response schema, loading related data.
+ """
+ # Load author if not already loaded
+ author = review.author
+
+ return ReviewResponse(
+ id=review.id,
+ venue_id=review.venue_id,
+ booking_id=review.booking_id,
+ user_id=review.user_id,
+ rating=review.rating,
+ title=review.title,
+ comment=review.comment,
+ is_hidden=review.is_hidden,
+ created_at=review.created_at,
+ updated_at=review.updated_at,
+ user_name=author.full_name if author else None,
+ user_email=author.email if author else None,
+ hidden_reason=review.hidden_reason,
+ hidden_by=review.hidden_by,
+ hidden_at=review.hidden_at,
+ )
\ No newline at end of file
diff --git a/apps/api/app/modules/search/category_intent.py b/apps/api/app/modules/search/category_intent.py
new file mode 100644
index 000000000..2383d30ea
--- /dev/null
+++ b/apps/api/app/modules/search/category_intent.py
@@ -0,0 +1,119 @@
+"""Detects which venue-category "intent" a search query implies, so hybrid
+search can boost only the categories the query actually seems to be about
+instead of always boosting wedding venues regardless of what was searched.
+
+Deliberately simple: a token-overlap check against three small term sets.
+Not a classifier — just enough to stop "rooftop party bangalore" from
+getting a wedding-hall boost it has nothing to do with, and (as of this
+version) to stop "corporate offsite bangalore" from boosting rooftop/resort
+venues instead of the conference/meeting/auditorium venues it actually
+means.
+
+The boost *group names* (GROUP_WEDDING / GROUP_EVENT / GROUP_CORPORATE) are
+the same strings stored in VenueCategory.search_boost_group — see
+search_metadata_cache.py — so a category's DB tag and a query's detected
+intent line up automatically.
+
+The boost *magnitudes* live in settings so they can be tuned without a
+deploy.
+"""
+
+from app.core.config import settings
+
+# Must match the values used in VenueCategory.search_boost_group.
+GROUP_WEDDING = "wedding_hall_banquet_hall"
+GROUP_EVENT = "event_space_rooftop_resort_lawn"
+GROUP_CORPORATE = (
+ "corporate_conference_meeting" # conference_room, meeting_room, auditorium
+)
+
+# Terms that suggest the query is about wedding/marriage-function venues.
+_WEDDING_INTENT_TERMS = {
+ "wedding",
+ "marriage",
+ "reception",
+ "mandap",
+ "sadya",
+ "kalyanam",
+ "kalyana",
+ "vivaham",
+ "nikah",
+ "shaadi",
+ "shadi",
+ "vivah",
+ "engagement",
+ "muhurtham",
+ "sangeet",
+ "mehendi",
+ "haldi",
+ "baraat",
+ "banquet",
+}
+
+# Terms that suggest the query is about party/event-style venues
+# (rooftop, club, resort, lawn, event_space).
+# NOTE: "corporate"/"conference" used to live here, which meant a corporate
+# offsite query was boosting resort/rooftop/lawn venues instead of the
+# conference/meeting/auditorium venues it actually meant. Moved to
+# _CORPORATE_INTENT_TERMS below.
+_EVENT_INTENT_TERMS = {
+ "party",
+ "rooftop",
+ "club",
+ "nightclub",
+ "lounge",
+ "birthday",
+ "anniversary",
+ "celebration",
+ "resort",
+ "lawn",
+ "getaway",
+ "staycation",
+ "event",
+ "dj",
+ "sundowner",
+}
+
+# Terms that suggest the query is about formal corporate/institutional
+# venues (conference rooms, meeting rooms, auditoriums).
+_CORPORATE_INTENT_TERMS = {
+ "corporate",
+ "conference",
+ "meeting",
+ "boardroom",
+ "seminar",
+ "convocation",
+ "offsite",
+ "training",
+ "workshop",
+ "interview",
+ "auditorium",
+}
+
+NO_BOOST = 1.00
+
+
+def detect_category_intents(query: str) -> dict[str, float]:
+ """Return the boost multiplier to apply per category group, based on
+ whether the (normalized) query's tokens overlap with that group's terms.
+ Groups with no signal in the query get a neutral 1.0 — i.e. no boost.
+ """
+ tokens = set(query.lower().split()) if query else set()
+
+ wedding_boost = (
+ settings.search_wedding_boost if tokens & _WEDDING_INTENT_TERMS else NO_BOOST
+ )
+ event_boost = (
+ settings.search_event_boost if tokens & _EVENT_INTENT_TERMS else NO_BOOST
+ )
+ corporate_boost = (
+ settings.search_corporate_boost
+ if tokens & _CORPORATE_INTENT_TERMS
+ else NO_BOOST
+ )
+
+ return {
+ GROUP_WEDDING: wedding_boost,
+ GROUP_EVENT: event_boost,
+ GROUP_CORPORATE: corporate_boost,
+ }
diff --git a/apps/api/app/modules/search/indexer.py b/apps/api/app/modules/search/indexer.py
new file mode 100644
index 000000000..d70ac83db
--- /dev/null
+++ b/apps/api/app/modules/search/indexer.py
@@ -0,0 +1,191 @@
+import logging
+import uuid
+from datetime import datetime, timezone
+from uuid import UUID
+
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session, joinedload
+
+from app.core import job_queue
+from app.core import redis as redis_client
+from app.core.config import settings
+from app.infrastructure.embeddings.jina import embed_passage, embed_query
+from app.modules.search import search_metadata_cache
+from app.modules.search.models import SearchIndexJob
+from app.modules.venue.models import Venue
+
+logger = logging.getLogger(__name__)
+
+# Exponential backoff delays in seconds per retry attempt index.
+# Attempt 0 → immediate, 1 → 5 min, 2 → 15 min, 3 → 1 hr, 4 → 6 hr
+_BACKOFF_SECONDS = [0, 300, 900, 3600, 21600]
+MAX_RETRIES = len(_BACKOFF_SECONDS) # was a hardcoded `5` in two places below
+
+
+def enqueue_job(db: Session, entity_id: UUID, operation: str) -> None:
+ """Insert a pending search index job and try to push to Upstash (fire-and-forget).
+
+ If a pending/processing job already exists for this entity the partial unique
+ index raises IntegrityError — we treat that as a no-op (the outstanding job
+ will pick up the latest state when it runs).
+ """
+ job = SearchIndexJob(
+ entity_type="venue",
+ entity_id=entity_id,
+ operation=operation,
+ )
+ db.add(job)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ return
+
+ job_id = str(job.id)
+
+ try:
+ if redis_client.is_configured():
+ redis_client.get_redis().lpush(settings.upstash_search_queue_key, job_id)
+ except Exception:
+ # Redis push failure is non-fatal — the APScheduler worker will poll the DB.
+ pass
+
+
+def _build_search_document(venue: Venue) -> str:
+ """Rich document for better semantic search"""
+ parts = [venue.name]
+
+ if venue.description and len(venue.description.strip()) > 10:
+ parts.append(venue.description.strip())
+
+ parts.append(f"{venue.city} {venue.state} India")
+
+ if venue.category:
+ slug = venue.category.slug
+ parts.append(venue.category.label)
+
+ # Was a hardcoded dict of slug -> synonym string; now sourced from
+ # VenueCategory.search_keywords via the startup-loaded cache, so
+ # adding/editing a category's synonyms doesn't require a deploy.
+ keywords = search_metadata_cache.keywords_for(slug)
+ if keywords:
+ parts.append(keywords)
+ elif not search_metadata_cache.is_loaded():
+ logger.warning(
+ "search_metadata_cache not loaded when indexing venue %s — "
+ "keyword expansion skipped for this document",
+ venue.id,
+ )
+
+ parts.append(f"capacity {venue.max_capacity} pax people guests")
+ if venue.min_capacity and venue.min_capacity > 0:
+ parts.append(f"minimum {venue.min_capacity} guests")
+
+ if venue.amenities:
+ parts.append(" ".join(a.name for a in venue.amenities))
+
+ return "\n".join(parts)
+
+
+def _update_fts(db: Session, venue_id: UUID, document: str) -> None:
+ db.execute(
+ text(
+ "UPDATE venues SET search_vector = to_tsvector('english', :doc) WHERE id = :id"
+ ),
+ {"doc": document, "id": str(venue_id)},
+ )
+
+
+def generate_query_embedding(query: str) -> list[float]:
+ """Kept as a thin wrapper so search/service.py doesn't need to import the
+ embeddings client directly — it only ever talks to the search module."""
+ return embed_query(query)
+
+
+def process_job(db: Session, job_id: str) -> None:
+ """Process a single search index job end-to-end."""
+ job = (
+ db.query(SearchIndexJob).filter(SearchIndexJob.id == uuid.UUID(job_id)).first()
+ )
+ if not job:
+ logger.warning("search_indexer: job %s not found", job_id)
+ return
+ if job.status != "pending":
+ logger.debug("search_indexer: job %s already %s, skipping", job_id, job.status)
+ return
+
+ job.status = "processing"
+ job.started_at = datetime.now(timezone.utc)
+ db.commit()
+
+ try:
+ venue = (
+ db.query(Venue)
+ .options(joinedload(Venue.category), joinedload(Venue.amenities))
+ .filter(Venue.id == job.entity_id)
+ .first()
+ )
+ if not venue:
+ raise ValueError(f"Venue {job.entity_id} not found")
+
+ document = _build_search_document(venue)
+
+ _update_fts(db, venue.id, document)
+
+ if settings.jina_api_key:
+ embedding = embed_passage(document)
+ venue.embedding = embedding
+ venue.embedding_updated_at = datetime.now(timezone.utc)
+
+ job.status = "completed"
+ job.completed_at = datetime.now(timezone.utc)
+ db.commit()
+ logger.info("search_indexer: job %s completed for venue %s", job_id, venue.id)
+
+ except Exception as exc:
+ db.rollback()
+ job = (
+ db.query(SearchIndexJob)
+ .filter(SearchIndexJob.id == uuid.UUID(job_id))
+ .first()
+ )
+ if job:
+ job.retry_count += 1
+ job.error_message = str(exc)
+ job.status = (
+ "failed" if job.retry_count < MAX_RETRIES else "failed_permanently"
+ )
+ db.commit()
+ logger.error("search_indexer: job %s failed (%s)", job_id, exc)
+
+
+def retryable_job_ids(db: Session, limit: int = 10) -> list[str]:
+ """Return job IDs eligible for processing: pending or failed-with-backoff-elapsed."""
+ pending = (
+ db.query(SearchIndexJob)
+ .filter(SearchIndexJob.status == "pending")
+ .order_by(SearchIndexJob.created_at.asc())
+ .limit(limit)
+ .all()
+ )
+
+ results = list(pending)
+
+ if len(results) < limit:
+ failed = (
+ db.query(SearchIndexJob)
+ .filter(
+ SearchIndexJob.status == "failed",
+ SearchIndexJob.retry_count < MAX_RETRIES,
+ )
+ .order_by(SearchIndexJob.created_at.asc())
+ .all()
+ )
+ for job in failed:
+ if len(results) >= limit:
+ break
+ if job_queue.is_backoff_eligible(job.created_at, job.retry_count, _BACKOFF_SECONDS):
+ results.append(job)
+
+ return [str(j.id) for j in results]
diff --git a/apps/api/app/modules/search/models.py b/apps/api/app/modules/search/models.py
new file mode 100644
index 000000000..6248f2801
--- /dev/null
+++ b/apps/api/app/modules/search/models.py
@@ -0,0 +1,21 @@
+import uuid
+from datetime import datetime
+from sqlalchemy import Integer, Text, DateTime, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import mapped_column, Mapped
+from app.core.database import Base
+
+
+class SearchIndexJob(Base):
+ __tablename__ = "search_index_jobs"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ entity_type: Mapped[str] = mapped_column(Text, nullable=False)
+ entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
+ operation: Mapped[str] = mapped_column(Text, nullable=False) # create | update | delete | reindex
+ status: Mapped[str] = mapped_column(Text, nullable=False, server_default="pending")
+ retry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
diff --git a/apps/api/app/modules/search/query_normalizer.py b/apps/api/app/modules/search/query_normalizer.py
new file mode 100644
index 000000000..c59cf4e64
--- /dev/null
+++ b/apps/api/app/modules/search/query_normalizer.py
@@ -0,0 +1,87 @@
+"""Lightweight typo correction for search queries.
+
+Fuzzy-matches each token in the raw query against a vocabulary of domain
+terms and swaps in the closest match when confidence is high. Intentionally
+cheap and dependency-light — no ML, no external calls — just a
+pre-processing pass before FTS/vector search runs.
+
+The vocabulary has two parts:
+ - _STATIC_SYNONYMS: linguistic spelling variants (e.g. "shaadi"/"vivah")
+ that aren't really DB data — these stay hardcoded since they're
+ language, not content.
+ - dynamic vocabulary: city names + category slugs/labels currently in the
+ DB, injected at startup via set_dynamic_vocabulary() so new cities or
+ categories don't require a code change. See
+ search_metadata_cache.build_query_vocabulary() and main.py's lifespan.
+"""
+
+from rapidfuzz import fuzz, process
+
+from app.core.config import settings
+
+_STATIC_SYNONYMS = [
+ "wedding",
+ "marriage",
+ "reception",
+ "function",
+ "mandap",
+ "sadya",
+ "kalyanam",
+ "vivaham",
+ "engagement",
+ "muhurtham",
+ "nikah",
+ "shaadi",
+ "mehendi",
+ "sangeet",
+ "baraat",
+]
+
+# Populated at startup from the DB — see set_dynamic_vocabulary().
+_dynamic_vocabulary: list[str] = []
+
+
+def set_dynamic_vocabulary(terms: list[str]) -> None:
+ """Replace the DB-sourced portion of the vocabulary. Call once at
+ startup (main.py's lifespan) and again whenever categories/cities
+ change meaningfully, if you want that reflected without a restart."""
+ global _dynamic_vocabulary
+ _dynamic_vocabulary = terms
+
+
+def _vocabulary() -> list[str]:
+ return _STATIC_SYNONYMS + _dynamic_vocabulary
+
+
+def normalize_query(q: str) -> str:
+ """Fuzzy-correct likely typos in each token of a search query.
+
+ Falls back to the original token whenever no confident vocabulary match
+ is found, so this only ever tightens spelling — it never invents terms
+ that weren't plausibly intended.
+ """
+ if not q or not q.strip():
+ return q
+
+ vocabulary = _vocabulary()
+ if not vocabulary:
+ # Dynamic vocabulary hasn't loaded yet (e.g. called before startup
+ # finished) — fall back to the static synonym list only rather than
+ # silently no-op'ing typo correction entirely.
+ vocabulary = _STATIC_SYNONYMS
+
+ tokens = q.strip().split()
+ corrected_tokens = []
+
+ for token in tokens:
+ if len(token) < settings.search_normalizer_min_token_len:
+ corrected_tokens.append(token)
+ continue
+
+ match = process.extractOne(token.lower(), vocabulary, scorer=fuzz.ratio)
+ if match and match[1] >= settings.search_normalizer_match_threshold:
+ corrected_tokens.append(match[0])
+ else:
+ corrected_tokens.append(token)
+
+ return " ".join(corrected_tokens)
diff --git a/apps/api/app/modules/search/routes.py b/apps/api/app/modules/search/routes.py
new file mode 100644
index 000000000..382caf4b4
--- /dev/null
+++ b/apps/api/app/modules/search/routes.py
@@ -0,0 +1,51 @@
+from fastapi import APIRouter, Query, Depends
+from sqlalchemy.orm import Session
+from app.core.database import get_db
+from app.modules.search.schemas import SearchParams, SearchResult
+from app.modules.search import service
+from app.shared.pagination import Page
+
+router = APIRouter()
+
+
+def _params(
+ q: str = Query(default=""),
+ city: str = Query(default=""),
+ venue_type: str | None = Query(default=None),
+ capacity: int = Query(default=0),
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+) -> SearchParams:
+ return SearchParams(q=q, city=city, venue_type=venue_type, capacity=capacity, page=page, page_size=page_size)
+
+
+@router.get("/", response_model=Page[SearchResult])
+def search_venues(
+ params: SearchParams = Depends(_params),
+ db: Session = Depends(get_db),
+):
+ return service.search(db, params)
+
+
+@router.get("/fts", response_model=Page[SearchResult])
+def search_fts(
+ params: SearchParams = Depends(_params),
+ db: Session = Depends(get_db),
+):
+ return service.search_fts(db, params)
+
+
+@router.get("/semantic", response_model=Page[SearchResult])
+def search_semantic(
+ params: SearchParams = Depends(_params),
+ db: Session = Depends(get_db),
+):
+ return service.search_semantic(db, params)
+
+
+@router.get("/hybrid", response_model=Page[SearchResult])
+def search_hybrid(
+ params: SearchParams = Depends(_params),
+ db: Session = Depends(get_db),
+):
+ return service.search_hybrid(db, params)
diff --git a/apps/api/app/modules/search/schemas.py b/apps/api/app/modules/search/schemas.py
new file mode 100644
index 000000000..59066fa19
--- /dev/null
+++ b/apps/api/app/modules/search/schemas.py
@@ -0,0 +1,36 @@
+from pydantic import BaseModel
+from typing import Optional
+from uuid import UUID
+from app.modules.venue.schemas import VenueCategoryResponse
+
+
+class SearchParams(BaseModel):
+ q: str = ""
+ city: str = ""
+ venue_type: Optional[str] = None # slug — kept for URL backward compat
+ capacity: int = 0
+ page: int = 1
+ page_size: int = 20
+
+
+class SearchResult(BaseModel):
+ id: UUID
+ name: str
+ city: str
+ category: VenueCategoryResponse
+ capacity: int
+ pricing_mode: str
+ starting_price_paise: Optional[int] = None
+ display_price_min_paise: Optional[int] = None
+ display_price_max_paise: Optional[int] = None
+ cover_photo_url: Optional[str] = None
+ is_liked: bool = False
+ # Match diagnostics — only populated by search_hybrid (None for plain
+ # /search, /search/fts, /search/semantic). Lets a caller (e.g. Deep
+ # Research's citation UI) show which signal(s) actually matched and how
+ # strongly, without re-deriving it client-side.
+ match_source: Optional[str] = None # "hybrid" | "semantic" | "keyword"
+ fts_score: Optional[float] = None
+ vector_score: Optional[float] = None
+ category_boost: Optional[float] = None
+ match_score: Optional[float] = None
diff --git a/apps/api/app/modules/search/search_metadata_cache.py b/apps/api/app/modules/search/search_metadata_cache.py
new file mode 100644
index 000000000..29b5941bd
--- /dev/null
+++ b/apps/api/app/modules/search/search_metadata_cache.py
@@ -0,0 +1,107 @@
+"""In-memory cache of search-relevant metadata, populated from the DB at
+app startup (and reloadable on demand from the admin routes).
+
+This exists to kill three copies of the same information that used to live
+as hardcoded Python literals:
+ - indexer.py's per-slug keyword-expansion dict
+ - service.py's SQL boost CASE slug lists
+ - query_normalizer.py's hardcoded city/category vocabulary
+
+Now there's one source of truth: VenueCategory.search_boost_group and
+VenueCategory.search_keywords columns (admin-editable), plus the distinct
+city/slug values already in the venues/venue_categories tables.
+
+Requires two new columns on VenueCategory — see the accompanying migration
+`XXXX_add_search_metadata_to_venue_categories.py`:
+ search_boost_group: str | None # e.g. "wedding_hall_banquet_hall"
+ search_keywords: str | None # space-separated synonym string
+"""
+
+import logging
+from threading import Lock
+
+from sqlalchemy.orm import Session
+
+from app.modules.venue.models import Venue, VenueCategory
+
+logger = logging.getLogger(__name__)
+
+_lock = Lock()
+_boost_groups: dict[str, str] = {}
+_keywords: dict[str, str] = {}
+_loaded = False
+
+
+def load_category_metadata(db: Session) -> None:
+ """Populate the boost-group / keyword cache from the DB. Call once at
+ startup, and again whenever a category's search metadata is edited via
+ the admin routes (call reload from there rather than waiting for a
+ restart)."""
+ global _boost_groups, _keywords, _loaded
+
+ categories = db.query(VenueCategory).all()
+ boost_groups = {
+ c.slug: c.search_boost_group for c in categories if c.search_boost_group
+ }
+ keywords = {c.slug: c.search_keywords for c in categories if c.search_keywords}
+
+ with _lock:
+ _boost_groups = boost_groups
+ _keywords = keywords
+ _loaded = True
+
+ logger.info(
+ "search_metadata_cache: loaded %d categories (%d with boost group, %d with keywords)",
+ len(categories),
+ len(boost_groups),
+ len(keywords),
+ )
+
+
+def boost_group_for(slug: str | None) -> str | None:
+ if not slug:
+ return None
+ return _boost_groups.get(slug)
+
+
+def keywords_for(slug: str | None) -> str | None:
+ if not slug:
+ return None
+ return _keywords.get(slug)
+
+
+def slugs_in_group(group: str) -> list[str]:
+ """Slugs tagged with a given boost group, e.g. all slugs tagged
+ 'wedding_hall_banquet_hall'. Used to build the SQL boost CASE at query
+ time instead of a hardcoded IN (...) list."""
+ return [slug for slug, g in _boost_groups.items() if g == group]
+
+
+def is_loaded() -> bool:
+ return _loaded
+
+
+def build_query_vocabulary(db: Session) -> list[str]:
+ """Distinct city names + category labels/slugs currently in the DB,
+ for the query normalizer's fuzzy-match vocabulary. Combine this with a
+ small static list of linguistic synonyms (spelling variants like
+ "shaadi"/"vivah") that aren't really DB data — that part stays in
+ query_normalizer.py.
+ """
+ cities = [
+ row[0]
+ for row in db.query(Venue.city).distinct().filter(Venue.city.isnot(None)).all()
+ if row[0]
+ ]
+ categories = db.query(VenueCategory).all()
+ category_terms: list[str] = []
+ for c in categories:
+ category_terms.append(c.slug.replace("_", " "))
+ if c.label:
+ category_terms.append(c.label.lower())
+
+ vocabulary = sorted({t.lower() for t in cities + category_terms if t})
+ logger.info(
+ "search_metadata_cache: built %d dynamic vocabulary terms", len(vocabulary)
+ )
+ return vocabulary
diff --git a/apps/api/app/modules/search/service.py b/apps/api/app/modules/search/service.py
new file mode 100644
index 000000000..1a91f7be4
--- /dev/null
+++ b/apps/api/app/modules/search/service.py
@@ -0,0 +1,532 @@
+import logging
+from uuid import UUID
+
+import numpy as np
+from sqlalchemy import func as sa_func, text, or_
+from sqlalchemy.orm import Session, joinedload
+
+from app.modules.search import search_metadata_cache
+from app.modules.search.category_intent import (
+ GROUP_CORPORATE,
+ GROUP_EVENT,
+ GROUP_WEDDING,
+ detect_category_intents,
+)
+from app.modules.search.query_normalizer import normalize_query
+from app.modules.search.schemas import SearchParams, SearchResult
+from app.modules.venue.models import Venue, VenueCategory, VenueStatus, VenuePhoto
+from app.shared.pagination import Page
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def _base_query(db: Session, params: SearchParams):
+ """Base approved/active venue query with city, type, and capacity filters applied."""
+ query = (
+ db.query(Venue)
+ .options(joinedload(Venue.category))
+ .filter(
+ Venue.status == VenueStatus.approved,
+ Venue.is_active == True,
+ Venue.deleted_at.is_(None),
+ )
+ )
+ if params.city:
+ query = query.filter(Venue.city.ilike(f"%{params.city}%"))
+ if params.venue_type:
+ query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter(
+ VenueCategory.slug == params.venue_type
+ )
+ if params.capacity > 0:
+ query = query.filter(Venue.max_capacity >= params.capacity)
+ return query
+
+
+def _cover_photos(db: Session, venue_ids: list) -> dict:
+ if not venue_ids:
+ return {}
+ photos = (
+ db.query(VenuePhoto)
+ .filter(
+ VenuePhoto.venue_id.in_(venue_ids),
+ VenuePhoto.is_cover == True,
+ VenuePhoto.deleted_at.is_(None),
+ )
+ .all()
+ )
+ return {p.venue_id: p.image_url for p in photos}
+
+
+def _match_source(fts_matched: bool, vector_matched: bool) -> str:
+ if fts_matched and vector_matched:
+ return "hybrid"
+ if vector_matched:
+ return "semantic"
+ return "keyword"
+
+
+def _to_results(
+ venues: list[Venue], cover_photos: dict, scores: dict | None = None
+) -> list[SearchResult]:
+ scores = scores or {}
+ results = []
+ for v in venues:
+ starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise
+ row_scores = scores.get(v.id)
+ results.append(SearchResult(
+ id=v.id,
+ name=v.name,
+ city=v.city,
+ category=v.category,
+ capacity=v.max_capacity,
+ pricing_mode=v.pricing_mode,
+ starting_price_paise=starting_price,
+ display_price_min_paise=v.display_price_min_paise,
+ display_price_max_paise=v.display_price_max_paise,
+ cover_photo_url=cover_photos.get(v.id),
+ match_source=(
+ _match_source(row_scores["fts_matched"], row_scores["vector_matched"])
+ if row_scores
+ else None
+ ),
+ fts_score=row_scores["fts_score"] if row_scores else None,
+ vector_score=row_scores["vector_score"] if row_scores else None,
+ category_boost=row_scores["boost"] if row_scores else None,
+ match_score=row_scores["hybrid_score"] if row_scores else None,
+ ))
+ return results
+
+
+def search(db: Session, params: SearchParams) -> Page[SearchResult]:
+ query = (
+ db.query(Venue)
+ .options(joinedload(Venue.category))
+ .filter(
+ Venue.status == VenueStatus.approved,
+ Venue.is_active == True,
+ Venue.deleted_at.is_(None),
+ )
+ )
+
+ if params.q:
+ search_term = f"%{params.q}%"
+ query = query.filter(
+ or_(
+ Venue.name.ilike(search_term),
+ Venue.description.ilike(search_term),
+ Venue.city.ilike(search_term),
+ Venue.state.ilike(search_term),
+ )
+ )
+
+ if params.city:
+ query = query.filter(Venue.city.ilike(f"%{params.city}%"))
+
+ if params.venue_type:
+ query = query.join(VenueCategory, Venue.category_id == VenueCategory.id).filter(
+ VenueCategory.slug == params.venue_type
+ )
+
+ if params.capacity > 0:
+ query = query.filter(Venue.max_capacity >= params.capacity)
+
+ total_count = query.count()
+
+ offset = (params.page - 1) * params.page_size
+ venues = (
+ query.order_by(Venue.created_at.desc())
+ .offset(offset)
+ .limit(params.page_size)
+ .all()
+ )
+
+ venue_ids = [v.id for v in venues]
+ cover_photos = {}
+ if venue_ids:
+ photos = db.query(VenuePhoto).filter(
+ VenuePhoto.venue_id.in_(venue_ids),
+ VenuePhoto.is_cover == True,
+ VenuePhoto.deleted_at.is_(None),
+ ).all()
+ cover_photos = {p.venue_id: p.image_url for p in photos}
+
+ results = []
+ for v in venues:
+ starting_price = v.starting_price_paise if v.pricing_mode in ('flat', 'mixed') else v.hourly_rate_paise
+ results.append(SearchResult(
+ id=v.id,
+ name=v.name,
+ city=v.city,
+ category=v.category,
+ capacity=v.max_capacity,
+ pricing_mode=v.pricing_mode,
+ starting_price_paise=starting_price,
+ display_price_min_paise=v.display_price_min_paise,
+ display_price_max_paise=v.display_price_max_paise,
+ cover_photo_url=cover_photos.get(v.id),
+ ))
+
+ return Page(
+ items=_to_results(venues, covers),
+ total=total_count,
+ page=params.page,
+ page_size=params.page_size,
+ )
+
+
+# ── FTS search ────────────────────────────────────────────────────────────────
+
+
+def search_fts(db: Session, params: SearchParams) -> Page[SearchResult]:
+ """Full-text search ranked by ts_rank. Requires search_vector to be populated."""
+ query = _base_query(db, params)
+
+ if params.q:
+ ts_query = sa_func.plainto_tsquery("english", params.q)
+ query = query.filter(
+ Venue.search_vector.op("@@")(ts_query),
+ ).order_by(sa_func.ts_rank(Venue.search_vector, ts_query).desc())
+ else:
+ query = query.order_by(Venue.created_at.desc())
+
+ total_count = query.count()
+ offset = (params.page - 1) * params.page_size
+ venues = query.offset(offset).limit(params.page_size).all()
+ covers = _cover_photos(db, [v.id for v in venues])
+ return Page(
+ items=_to_results(venues, covers),
+ total=total_count,
+ page=params.page,
+ page_size=params.page_size,
+ )
+
+
+# ── Semantic search ───────────────────────────────────────────────────────────
+
+
+def search_semantic(db: Session, params: SearchParams) -> Page[SearchResult]:
+ """Vector similarity search using Jina embeddings. Requires embedding to be populated."""
+ from app.modules.search.indexer import generate_query_embedding
+
+ query = _base_query(db, params).filter(Venue.embedding.isnot(None))
+
+ if params.q:
+ try:
+ query_vec = generate_query_embedding(params.q)
+ query = query.order_by(Venue.embedding.op("<=>")(query_vec).asc())
+ except Exception as exc:
+ logger.warning(
+ "search_semantic: embedding generation failed (%s), falling back to FTS",
+ exc,
+ )
+ return search_fts(db, params)
+ else:
+ query = query.order_by(Venue.created_at.desc())
+
+ total_count = query.count()
+ offset = (params.page - 1) * params.page_size
+ venues = query.offset(offset).limit(params.page_size).all()
+ covers = _cover_photos(db, [v.id for v in venues])
+ return Page(
+ items=_to_results(venues, covers),
+ total=total_count,
+ page=params.page,
+ page_size=params.page_size,
+ )
+
+
+# ── Hybrid search ─────────────────────────────────────────────────────────────
+
+
+def _has_any_embeddings(db: Session) -> bool:
+ """Cheap existence check — was `.limit(1).count()`, which still runs a
+ count aggregate. `.exists()` short-circuits at the first matching row."""
+ exists_query = (
+ db.query(Venue.id)
+ .filter(
+ Venue.status == VenueStatus.approved,
+ Venue.is_active == True,
+ Venue.deleted_at.is_(None),
+ Venue.embedding.isnot(None),
+ )
+ .exists()
+ )
+ return db.query(exists_query).scalar()
+
+
+def _log_hybrid_diagnostics(
+ db: Session,
+ raw_query: str,
+ normalized_q: str,
+ query_vec: list[float],
+ intents: dict,
+) -> None:
+ """Debug diagnostics comparing the FTS-only and vector-only result sets.
+
+ Runs 3 extra queries — only call this when settings.search_diagnostics_enabled
+ is on (dev/staging), not unconditionally on every production request.
+ """
+ try:
+ vec_norm = float(np.linalg.norm(np.array(query_vec, dtype=np.float32)))
+
+ common_where = """
+ v.status = 'approved' AND v.is_active = true AND v.deleted_at IS NULL
+ """
+
+ fts_count_sql = text(f"""
+ SELECT COUNT(*) FROM venues v
+ WHERE {common_where}
+ AND v.search_vector @@ plainto_tsquery('english', :q)
+ """)
+ fts_matches = db.execute(fts_count_sql, {"q": normalized_q}).scalar() or 0
+
+ fts_top_sql = text(f"""
+ SELECT v.id FROM venues v
+ WHERE {common_where}
+ AND v.search_vector @@ plainto_tsquery('english', :q)
+ ORDER BY ts_rank(v.search_vector, plainto_tsquery('english', :q)) DESC
+ LIMIT 10
+ """)
+ fts_top_ids = [
+ str(r.id) for r in db.execute(fts_top_sql, {"q": normalized_q}).fetchall()
+ ]
+
+ vec_top_sql = text(f"""
+ SELECT v.id FROM venues v
+ WHERE {common_where}
+ AND v.embedding IS NOT NULL
+ ORDER BY v.embedding <=> CAST(:qvec AS vector) ASC
+ LIMIT 10
+ """)
+ vec_top_ids = [
+ str(r.id)
+ for r in db.execute(vec_top_sql, {"qvec": str(query_vec)}).fetchall()
+ ]
+
+ overlap = len(set(fts_top_ids) & set(vec_top_ids))
+
+ logger.info(
+ "search_hybrid diagnostics | "
+ "raw_query=%r normalized_query=%r fts_matches=%d "
+ "query_vec_norm=%.4f vector_threshold=%.2f "
+ "fts_top10=%s vector_top10=%s overlap=%d "
+ "wedding_boost=%.2f event_boost=%.2f",
+ raw_query,
+ normalized_q,
+ fts_matches,
+ vec_norm,
+ settings.search_min_vector_similarity,
+ fts_top_ids,
+ vec_top_ids,
+ overlap,
+ intents.get(GROUP_WEDDING, 1.0),
+ intents.get(GROUP_EVENT, 1.0),
+ )
+ except Exception:
+ logger.exception("search_hybrid: diagnostics logging failed")
+
+
+def _log_hybrid_result_scores(rows, limit: int = 20) -> None:
+ """Log the FTS/vector/boost/hybrid score breakdown for top results."""
+ try:
+ lines = [
+ f" #{i+1:>2} id={row.id} cat={row.category_slug or '-':<14} "
+ f"fts={row.fts_score:.4f} vec={row.vector_score:.4f} "
+ f"boost={row.boost:.2f} hybrid={row.hybrid_score:.4f} name={row.name!r}"
+ for i, row in enumerate(rows[:limit])
+ ]
+ logger.info(
+ "search_hybrid result scores (top %s):\n%s", len(lines), "\n".join(lines)
+ )
+ except Exception:
+ logger.exception("search_hybrid: result score logging failed")
+
+
+def search_hybrid(db: Session, params: SearchParams) -> Page[SearchResult]:
+ """Hybrid Search using Full-Text Search + Vector Search + Category Boost"""
+
+ if not params.q:
+ return search_fts(db, params)
+
+ raw_query = params.q
+ normalized_q = normalize_query(raw_query)
+
+ if not _has_any_embeddings(db):
+ return search_fts(db, params)
+
+ from app.modules.search.indexer import generate_query_embedding
+
+ try:
+ query_vec = generate_query_embedding(normalized_q)
+ except Exception as exc:
+ logger.warning("Embedding generation failed: %s", exc)
+ return search_fts(db, params)
+
+ intents = detect_category_intents(normalized_q)
+
+ if settings.search_diagnostics_enabled:
+ _log_hybrid_diagnostics(db, raw_query, normalized_q, query_vec, intents)
+
+ # Slug lists per boost group now come from the DB-backed cache instead
+ # of being hardcoded here — see search_metadata_cache.py. Passed as
+ # array bind params (`= ANY(:param)`), not string-interpolated, so a
+ # category added via the admin panel doesn't need a redeploy.
+ wedding_slugs = search_metadata_cache.slugs_in_group(GROUP_WEDDING)
+ event_slugs = search_metadata_cache.slugs_in_group(GROUP_EVENT)
+ corporate_slugs = search_metadata_cache.slugs_in_group(GROUP_CORPORATE)
+
+ base_filters = [
+ "v.status = 'approved'",
+ "v.is_active = true",
+ "v.deleted_at IS NULL",
+ """
+ (
+ v.search_vector @@ plainto_tsquery('english', :q)
+
+ OR
+
+ (
+ v.embedding IS NOT NULL
+ AND NOT (
+ v.search_vector @@ plainto_tsquery('english', :q)
+ )
+ AND (
+ 1 - (
+ v.embedding <=> CAST(:qvec AS vector)
+ )
+ ) >= :min_vector_score
+ )
+ )
+ """,
+ ]
+
+ query_params = {
+ "q": normalized_q,
+ "qvec": str(query_vec),
+ "min_vector_score": settings.search_min_vector_similarity,
+ # Boost values and slug lists are now bind params rather than
+ # f-string-interpolated literals: the SQL *text* is identical across
+ # calls regardless of which intent was detected, so Postgres can
+ # reuse a cached plan instead of re-planning every query.
+ "wedding_slugs": wedding_slugs,
+ "event_slugs": event_slugs,
+ "corporate_slugs": corporate_slugs,
+ "wedding_boost": intents.get(GROUP_WEDDING, 1.0),
+ "event_boost": intents.get(GROUP_EVENT, 1.0),
+ "corporate_boost": intents.get(GROUP_CORPORATE, 1.0),
+ "fts_weight": settings.search_fts_weight,
+ "vector_weight": settings.search_vector_weight,
+ }
+
+ if params.city:
+ base_filters.append("v.city ILIKE :city")
+ query_params["city"] = f"%{params.city}%"
+
+ if params.capacity > 0:
+ base_filters.append("v.max_capacity >= :capacity")
+ query_params["capacity"] = params.capacity
+
+ if params.venue_type:
+ base_filters.append("vc.slug = :venue_type")
+ query_params["venue_type"] = params.venue_type
+
+ where_clause = " AND ".join(base_filters)
+
+ query_params["limit"] = params.page_size
+ query_params["offset"] = (params.page - 1) * params.page_size
+
+ # Boost/score computed once in a CTE (was duplicated inline in both the
+ # `boost` column and `hybrid_score` expression) and total count comes
+ # from a window function on the same query instead of a separate
+ # COUNT(*) round trip over the whole WHERE clause.
+ rows_sql = text(f"""
+ WITH scored AS (
+ SELECT
+ v.id,
+ v.name,
+ vc.slug AS category_slug,
+ COALESCE(
+ ts_rank(v.search_vector, plainto_tsquery('english', :q)),
+ 0
+ ) AS fts_score,
+ COALESCE(
+ 1 - (v.embedding <=> CAST(:qvec AS vector)),
+ 0
+ ) AS vector_score,
+ (
+ v.search_vector @@ plainto_tsquery('english', :q)
+ ) AS fts_matched,
+ (
+ v.embedding IS NOT NULL
+ AND (1 - (v.embedding <=> CAST(:qvec AS vector))) >= :min_vector_score
+ ) AS vector_matched,
+ CASE
+ WHEN vc.slug = ANY(:wedding_slugs) THEN :wedding_boost
+ WHEN vc.slug = ANY(:event_slugs) THEN :event_boost
+ WHEN vc.slug = ANY(:corporate_slugs) THEN :corporate_boost
+ ELSE 1.00
+ END AS boost
+ FROM venues v
+ LEFT JOIN venue_categories vc ON vc.id = v.category_id
+ WHERE {where_clause}
+ )
+ SELECT
+ id,
+ name,
+ category_slug,
+ fts_score,
+ vector_score,
+ fts_matched,
+ vector_matched,
+ boost,
+ (:fts_weight * fts_score + :vector_weight * vector_score) * boost AS hybrid_score,
+ COUNT(*) OVER() AS total_count
+ FROM scored
+ ORDER BY hybrid_score DESC
+ LIMIT :limit
+ OFFSET :offset
+ """)
+
+ rows = db.execute(rows_sql, query_params).fetchall()
+
+ if settings.search_diagnostics_enabled:
+ _log_hybrid_result_scores(rows)
+
+ if not rows:
+ return Page(items=[], total=0, page=params.page, page_size=params.page_size)
+
+ total = rows[0].total_count
+ venue_ids: list[UUID] = [row.id for row in rows]
+ scores = {
+ row.id: {
+ "fts_score": row.fts_score,
+ "vector_score": row.vector_score,
+ "fts_matched": row.fts_matched,
+ "vector_matched": row.vector_matched,
+ "boost": row.boost,
+ "hybrid_score": row.hybrid_score,
+ }
+ for row in rows
+ }
+
+ # Fetch full venue objects with relationships
+ venues_by_id = {
+ venue.id: venue
+ for venue in (
+ db.query(Venue)
+ .options(joinedload(Venue.category))
+ .filter(Venue.id.in_(venue_ids))
+ .all()
+ )
+ }
+
+ venues = [venues_by_id[vid] for vid in venue_ids if vid in venues_by_id]
+ covers = _cover_photos(db, venue_ids)
+
+ return Page(
+ items=_to_results(venues, covers, scores),
+ total=total,
+ page=params.page,
+ page_size=params.page_size,
+ )
diff --git a/apps/api/app/modules/venue/models.py b/apps/api/app/modules/venue/models.py
new file mode 100644
index 000000000..8502bc1bf
--- /dev/null
+++ b/apps/api/app/modules/venue/models.py
@@ -0,0 +1,385 @@
+import enum
+import uuid
+from datetime import datetime, date, time
+from typing import Any
+from sqlalchemy import (
+ Integer, Boolean, Numeric, BigInteger, Text, Time, DateTime, Date,
+ ForeignKey, CheckConstraint, Index, func, Enum, text
+)
+from sqlalchemy.dialects.postgresql import UUID, ARRAY, TSVECTOR
+from sqlalchemy.orm import mapped_column, Mapped, relationship
+from pgvector.sqlalchemy import Vector
+from app.core.database import Base
+
+
+class VenueStatus(str, enum.Enum):
+ draft = "draft"
+ pending_approval = "pending_approval"
+ approved = "approved"
+ rejected = "rejected"
+ suspended = "suspended"
+
+
+class BookingMode(str, enum.Enum):
+ MANUAL = "MANUAL"
+ INSTANT = "INSTANT"
+
+
+
+class VenueCategory(Base):
+ __tablename__ = "venue_categories"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ slug: Mapped[str] = mapped_column(Text, nullable=False)
+ label: Mapped[str] = mapped_column(Text, nullable=False)
+ icon: Mapped[str | None] = mapped_column(Text, nullable=True)
+ banner_image: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ search_boost_group: Mapped[str | None] = mapped_column(Text, nullable=True)
+ search_keywords: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ __table_args__ = (
+ Index("uq_venue_categories_slug_active", "slug", unique=True, postgresql_where=text("deleted_at IS NULL")),
+ )
+
+ venues: Mapped[list["Venue"]] = relationship(back_populates="category")
+
+
+class Venue(Base):
+ __tablename__ = "venues"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False)
+
+ name: Mapped[str] = mapped_column(Text, nullable=False)
+ slug: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ category_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("venue_categories.id"), nullable=False)
+
+ address_line1: Mapped[str] = mapped_column(Text, nullable=False)
+ address_line2: Mapped[str | None] = mapped_column(Text, nullable=True)
+ city: Mapped[str] = mapped_column(Text, nullable=False)
+ state: Mapped[str] = mapped_column(Text, nullable=False)
+ country: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'India'"))
+ postal_code: Mapped[str | None] = mapped_column(Text, nullable=True)
+ latitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True)
+ longitude: Mapped[float | None] = mapped_column(Numeric(10, 7), nullable=True)
+ timezone: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'Asia/Kolkata'"))
+
+ min_capacity: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ max_capacity: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ open_time: Mapped[time] = mapped_column(Time, nullable=False)
+ close_time: Mapped[time] = mapped_column(Time, nullable=False)
+ spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+
+ allowed_booking_types: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=lambda: ["full_day", "time_slot"], server_default=text("ARRAY['full_day','time_slot']"))
+ min_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="60")
+ max_booking_duration_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1440")
+ slot_interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="30")
+
+ pre_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ post_buffer_minutes: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+
+ pricing_mode: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'flat'"))
+ starting_price_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+ hourly_rate_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+ platform_commission_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="10.00")
+ advance_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="30.00")
+
+ min_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="50.00")
+ max_price_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="200.00")
+ display_price_min_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+ display_price_max_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+ balance_due_days_before_event: Mapped[int] = mapped_column(Integer, nullable=False, server_default="7")
+ owner_action_window_hours: Mapped[int] = mapped_column(Integer, nullable=False, server_default="48")
+ overdue_advance_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0.00")
+
+ status: Mapped[VenueStatus] = mapped_column(
+ Enum(VenueStatus, name="venue_status"),
+ nullable=False,
+ server_default=text("'draft'")
+ )
+ booking_mode: Mapped[str] = mapped_column(
+ Text,
+ nullable=False,
+ server_default=text("'MANUAL'")
+ )
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
+ last_completed_step: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+
+ search_vector: Mapped[Any | None] = mapped_column(TSVECTOR, nullable=True)
+ embedding: Mapped[list[float] | None] = mapped_column(Vector(1024), nullable=True)
+ embedding_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+
+ # Relationships
+ category: Mapped["VenueCategory"] = relationship(back_populates="venues")
+ photos: Mapped[list["VenuePhoto"]] = relationship(
+ back_populates="venue",
+ cascade="all, delete-orphan",
+ primaryjoin="and_(Venue.id==VenuePhoto.venue_id, VenuePhoto.deleted_at.is_(None))",
+ order_by="VenuePhoto.sort_order"
+ )
+ amenities: Mapped[list["Amenity"]] = relationship(
+ secondary="venue_amenities",
+ back_populates="venues",
+ secondaryjoin="and_(VenueAmenity.amenity_id==Amenity.id, Amenity.deleted_at.is_(None))"
+ )
+ availability: Mapped[list["VenueAvailability"]] = relationship(
+ back_populates="venue",
+ cascade="all, delete-orphan",
+ primaryjoin="and_(Venue.id==VenueAvailability.venue_id, VenueAvailability.deleted_at.is_(None))"
+ )
+ blocked_dates: Mapped[list["VenueBlockedDate"]] = relationship(
+ back_populates="venue",
+ cascade="all, delete-orphan",
+ primaryjoin="and_(Venue.id==VenueBlockedDate.venue_id, VenueBlockedDate.deleted_at.is_(None))"
+ )
+ cancellation_policy: Mapped["VenueCancellationPolicy"] = relationship(back_populates="venue", uselist=False, cascade="all, delete-orphan")
+
+ __table_args__ = (
+ CheckConstraint("min_capacity IS NULL OR min_capacity <= max_capacity", name="ck_venues_capacity"),
+ CheckConstraint("min_capacity > 0", name="ck_venues_min_capacity"),
+ CheckConstraint("max_capacity > 0", name="ck_venues_max_capacity"),
+ CheckConstraint("min_booking_duration_minutes > 0", name="ck_venues_min_duration"),
+ CheckConstraint("max_booking_duration_minutes > 0", name="ck_venues_max_duration"),
+ CheckConstraint("slot_interval_minutes > 0", name="ck_venues_slot_interval"),
+ CheckConstraint("min_booking_duration_minutes <= max_booking_duration_minutes", name="ck_venues_duration_range"),
+ CheckConstraint("pre_buffer_minutes >= 0", name="ck_venues_pre_buffer"),
+ CheckConstraint("post_buffer_minutes >= 0", name="ck_venues_post_buffer"),
+ CheckConstraint("pricing_mode IN ('flat', 'hourly', 'mixed')", name="ck_venues_pricing_mode"),
+ CheckConstraint("starting_price_paise >= 0", name="ck_venues_base_price"),
+ CheckConstraint("hourly_rate_paise >= 0", name="ck_venues_hourly_rate"),
+ CheckConstraint("platform_commission_pct >= 0 AND platform_commission_pct <= 100", name="ck_venues_commission"),
+ CheckConstraint("advance_pct > 0 AND advance_pct <= 100", name="ck_venues_advance_pct"),
+ CheckConstraint("balance_due_days_before_event > 0", name="ck_venues_balance_days"),
+ CheckConstraint("owner_action_window_hours BETWEEN 24 AND 72", name="ck_venues_action_window"),
+ CheckConstraint("overdue_advance_refund_pct BETWEEN 0 AND 100", name="ck_venues_overdue_refund_pct"),
+ CheckConstraint("min_price_pct > 0 AND min_price_pct <= 100", name="ck_venues_min_price_pct"),
+ CheckConstraint("max_price_pct >= 100 AND max_price_pct <= 500", name="ck_venues_max_price_pct"),
+ CheckConstraint("min_price_pct <= max_price_pct", name="ck_venues_price_pct_range"),
+ Index("idx_venues_search", "city", "category_id", "status", "is_active", postgresql_where=text("deleted_at IS NULL")),
+ )
+
+ bookings = relationship(
+ "Booking",
+ back_populates="venue",
+ )
+
+ reviews: Mapped[list["VenueReview"]] = relationship(
+ back_populates="venue",
+ )
+
+ pricing_rules: Mapped[list["VenuePricingRule"]] = relationship(
+ back_populates="venue",
+ cascade="all, delete-orphan",
+ primaryjoin="and_(Venue.id==VenuePricingRule.venue_id, VenuePricingRule.deleted_at.is_(None))",
+ order_by="VenuePricingRule.priority.desc()",
+ )
+
+
+class VenuePhoto(Base):
+ __tablename__ = "venue_photos"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False)
+ image_url: Mapped[str] = mapped_column(Text, nullable=False)
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ is_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="photos")
+
+ __table_args__ = (
+ Index("venue_photos_one_cover", "venue_id", unique=True, postgresql_where=text("is_cover = true AND deleted_at IS NULL")),
+ )
+
+
+class Amenity(Base):
+ __tablename__ = "amenities"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name: Mapped[str] = mapped_column(Text, nullable=False)
+ icon: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+ __table_args__ = (
+ Index("uq_amenities_name_active", "name", unique=True, postgresql_where=text("deleted_at IS NULL")),
+ )
+
+ # Relationships
+ venues: Mapped[list["Venue"]] = relationship(secondary="venue_amenities", back_populates="amenities")
+
+
+class VenueAmenity(Base):
+ __tablename__ = "venue_amenities"
+
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), primary_key=True)
+ amenity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("amenities.id"), primary_key=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+
+class VenueAvailability(Base):
+ __tablename__ = "venue_availability"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False)
+ day_of_week: Mapped[int] = mapped_column(Integer, nullable=False)
+
+ is_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
+ opens_at: Mapped[time | None] = mapped_column(Time, nullable=True)
+ closes_at: Mapped[time | None] = mapped_column(Time, nullable=True)
+ spans_next_day: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="availability")
+
+ __table_args__ = (
+ CheckConstraint("day_of_week BETWEEN 0 AND 6", name="ck_venue_availability_day"),
+ CheckConstraint("is_available = false OR (opens_at IS NOT NULL AND closes_at IS NOT NULL)", name="ck_venue_availability_times"),
+ Index("venue_availability_unique_day", "venue_id", "day_of_week", unique=True, postgresql_where=text("deleted_at IS NULL")),
+ )
+
+
+class VenueBlockedDate(Base):
+ __tablename__ = "venue_blocked_dates"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False)
+ starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+ blocked_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id"), nullable=False)
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="blocked_dates")
+
+ __table_args__ = (
+ CheckConstraint("ends_at > starts_at", name="ck_venue_blocked_dates_order"),
+ Index("idx_venue_blocked_dates_venue", "venue_id", postgresql_where=text("deleted_at IS NULL")),
+ )
+
+
+class VenuePricingRule(Base):
+ __tablename__ = "venue_pricing_rules"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False)
+
+ name: Mapped[str] = mapped_column(Text, nullable=False)
+
+ days_of_week: Mapped[list[int] | None] = mapped_column(ARRAY(Integer), nullable=True)
+ start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+ end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+ start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
+ end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
+
+ adjustment_type: Mapped[str] = mapped_column(Text, nullable=False)
+ multiplier: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
+ amount_paise: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+
+ applies_to: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'both'"))
+ priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
+ source: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'owner'"))
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
+
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="pricing_rules")
+
+ __table_args__ = (
+ CheckConstraint("adjustment_type IN ('multiplier', 'fixed_delta', 'override')", name="ck_venue_pricing_rules_adjustment_type"),
+ CheckConstraint(
+ "(adjustment_type = 'multiplier' AND multiplier IS NOT NULL AND multiplier > 0 AND amount_paise IS NULL) OR "
+ "(adjustment_type = 'fixed_delta' AND amount_paise IS NOT NULL AND multiplier IS NULL) OR "
+ "(adjustment_type = 'override' AND amount_paise IS NOT NULL AND amount_paise >= 0 AND multiplier IS NULL)",
+ name="ck_venue_pricing_rules_adjustment_payload",
+ ),
+ CheckConstraint("applies_to IN ('full_day', 'time_slot', 'both')", name="ck_venue_pricing_rules_applies_to"),
+ CheckConstraint("source IN ('owner', 'system')", name="ck_venue_pricing_rules_source"),
+ CheckConstraint("start_date IS NULL OR end_date IS NULL OR start_date <= end_date", name="ck_venue_pricing_rules_date_range"),
+ Index(
+ "idx_venue_pricing_rules_priority",
+ "venue_id", "priority",
+ postgresql_where=text("deleted_at IS NULL AND is_active = true"),
+ ),
+ )
+
+
+class VenueCancellationPolicy(Base):
+ __tablename__ = "venue_cancellation_policies"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id"), nullable=False, unique=True)
+
+ tier_1_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ tier_1_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
+ tier_2_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ tier_2_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
+ tier_3_hours: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ tier_3_refund_pct: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
+
+ no_show_refund_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default="0")
+ platform_fee_refundable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+
+ # Relationships
+ venue: Mapped["Venue"] = relationship(back_populates="cancellation_policy")
+
+ __table_args__ = (
+ CheckConstraint("tier_1_hours > 0", name="ck_vcp_tier_1_hours"),
+ CheckConstraint("tier_1_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_1_pct"),
+ CheckConstraint("tier_2_hours > 0", name="ck_vcp_tier_2_hours"),
+ CheckConstraint("tier_2_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_2_pct"),
+ CheckConstraint("tier_3_hours > 0", name="ck_vcp_tier_3_hours"),
+ CheckConstraint("tier_3_refund_pct BETWEEN 0 AND 100", name="ck_vcp_tier_3_pct"),
+ CheckConstraint("no_show_refund_pct BETWEEN 0 AND 100", name="ck_vcp_no_show_pct"),
+ CheckConstraint(
+ "(tier_1_hours IS NULL OR tier_2_hours IS NULL OR tier_1_hours > tier_2_hours) AND "
+ "(tier_2_hours IS NULL OR tier_3_hours IS NULL OR tier_2_hours > tier_3_hours)",
+ name="tiers_descending"
+ ),
+ CheckConstraint("(tier_1_hours IS NULL) = (tier_1_refund_pct IS NULL)", name="tier_1_paired"),
+ CheckConstraint("(tier_2_hours IS NULL) = (tier_2_refund_pct IS NULL)", name="tier_2_paired"),
+ CheckConstraint("(tier_3_hours IS NULL) = (tier_3_refund_pct IS NULL)", name="tier_3_paired"),
+ )
+
+
+class VenueLike(Base):
+ __tablename__ = "venue_likes"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
+ venue_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("venues.id", ondelete="CASCADE"), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+ __table_args__ = (
+ Index("uq_venue_likes_user_venue", "user_id", "venue_id", unique=True),
+ )
diff --git a/apps/api/app/modules/venue/pricing_engine.py b/apps/api/app/modules/venue/pricing_engine.py
new file mode 100644
index 000000000..57c7b906b
--- /dev/null
+++ b/apps/api/app/modules/venue/pricing_engine.py
@@ -0,0 +1,114 @@
+from dataclasses import dataclass
+from datetime import date, time
+from decimal import Decimal, ROUND_HALF_EVEN
+from typing import Sequence
+from uuid import UUID
+
+from app.modules.venue.models import VenuePricingRule
+
+
+def _banker_round(value: Decimal) -> int:
+ """Banker's rounding to nearest integer for financial precision."""
+ return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))
+
+
+@dataclass
+class PricedUnit:
+ base_paise: int
+ final_paise: int
+ applied_rule_id: UUID | None
+ applied_rule_name: str | None
+ clamped: bool
+
+
+def _rule_matches_date(rule: VenuePricingRule, target_date: date) -> bool:
+ if rule.days_of_week is not None and target_date.weekday() not in rule.days_of_week:
+ return False
+ if rule.start_date is not None and target_date < rule.start_date:
+ return False
+ if rule.end_date is not None and target_date > rule.end_date:
+ return False
+ return True
+
+
+def _rule_matches_time(rule: VenuePricingRule, target_time: time | None) -> bool:
+ if rule.start_time is None and rule.end_time is None:
+ return True
+ if target_time is None:
+ return False
+ if rule.start_time is not None and target_time < rule.start_time:
+ return False
+ if rule.end_time is not None and target_time >= rule.end_time:
+ return False
+ return True
+
+
+def resolve_rule(
+ rules: Sequence[VenuePricingRule],
+ *,
+ target_date: date,
+ target_time: time | None,
+ applies_to: str,
+) -> VenuePricingRule | None:
+ """Highest-priority active rule matching the given date/time. No stacking."""
+ candidates = [
+ r for r in rules
+ if r.is_active
+ and r.deleted_at is None
+ and r.applies_to in (applies_to, "both")
+ and _rule_matches_date(r, target_date)
+ and _rule_matches_time(r, target_time)
+ ]
+ if not candidates:
+ return None
+ candidates.sort(key=lambda r: (r.priority, r.created_at), reverse=True)
+ return candidates[0]
+
+
+def apply_adjustment(base_paise: int, rule: VenuePricingRule | None) -> int:
+ if rule is None:
+ return base_paise
+ if rule.adjustment_type == "multiplier":
+ return _banker_round(Decimal(str(base_paise)) * Decimal(str(rule.multiplier)))
+ if rule.adjustment_type == "fixed_delta":
+ return max(0, base_paise + rule.amount_paise)
+ if rule.adjustment_type == "override":
+ return rule.amount_paise
+ return base_paise
+
+
+def clamp_price(
+ computed_paise: int,
+ base_paise: int,
+ min_price_pct: Decimal,
+ max_price_pct: Decimal,
+) -> tuple[int, bool]:
+ floor_paise = _banker_round(Decimal(str(base_paise)) * min_price_pct / Decimal("100"))
+ ceiling_paise = _banker_round(Decimal(str(base_paise)) * max_price_pct / Decimal("100"))
+ if computed_paise < floor_paise:
+ return floor_paise, True
+ if computed_paise > ceiling_paise:
+ return ceiling_paise, True
+ return computed_paise, False
+
+
+def price_unit(
+ rules: Sequence[VenuePricingRule],
+ *,
+ base_paise: int,
+ target_date: date,
+ target_time: time | None,
+ applies_to: str,
+ min_price_pct: Decimal,
+ max_price_pct: Decimal,
+) -> PricedUnit:
+ rule = resolve_rule(rules, target_date=target_date, target_time=target_time, applies_to=applies_to)
+ computed_paise = apply_adjustment(base_paise, rule)
+ final_paise, clamped = clamp_price(computed_paise, base_paise, min_price_pct, max_price_pct)
+ return PricedUnit(
+ base_paise=base_paise,
+ final_paise=final_paise,
+ applied_rule_id=rule.id if rule else None,
+ applied_rule_name=rule.name if rule else None,
+ clamped=clamped,
+ )
diff --git a/apps/api/app/modules/venue/routes.py b/apps/api/app/modules/venue/routes.py
new file mode 100644
index 000000000..f195932cb
--- /dev/null
+++ b/apps/api/app/modules/venue/routes.py
@@ -0,0 +1,393 @@
+from uuid import UUID
+from datetime import datetime
+from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.modules.auth.dependencies import (
+ require_owner,
+ require_auth,
+ get_current_user_optional,
+ AuthContext,
+)
+from app.modules.venue.schemas import (
+ VenueResponse,
+ VenueListResponse,
+ VenueStatsResponse,
+ VenueCategoryResponse,
+ CreateVenueRequest,
+ UpdateVenueRequest,
+ PricingPreviewResponse,
+ DeleteResponse,
+ VenueAvailabilityResponse,
+ BulkUpdateAvailabilityRequest,
+ VenueBlockedDateResponse,
+ CreateBlockedDateRequest,
+ CancellationPolicyResponse,
+ UpdateCancellationPolicyRequest,
+ AmenityResponse,
+ UpdateVenueAmenitiesRequest,
+ BookingType,
+ PublicVenueBlockedDateResponse,
+ VenuePhotoResponse,
+ BulkUpdateVenuePhotosRequest,
+ VenuePricingRuleResponse,
+ CreatePricingRuleRequest,
+ UpdatePricingRuleRequest,
+)
+from app.modules.venue import service
+from app.modules.booking import service as booking_service
+from app.modules.booking.schemas import BookingOut
+from app.shared.utils import parse_timezone_datetime
+
+router = APIRouter()
+
+
+# Owner routes
+
+
+@router.get("/my/venues", response_model=list[VenueListResponse])
+def list_my_venues(
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+
+ return service.list_owner_venues(db, owner_id=auth.user_id)
+
+
+@router.get("/my/venues/{venue_id}", response_model=VenueResponse)
+def get_my_venue(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ venue = service.get_owner_venue(db, venue_id=venue_id, owner_id=auth.user_id)
+ return service._enrich_venue_with_ratings(db, venue)
+
+
+@router.get("/my/venues/{venue_id}/stats", response_model=VenueStatsResponse)
+def get_my_venue_stats(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.get_venue_stats_this_month(
+ db, venue_id=venue_id, owner_id=auth.user_id
+ )
+
+
+@router.post("/", response_model=VenueResponse, status_code=201)
+def create_venue(
+ body: CreateVenueRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+
+ return service.create_venue(db, owner_id=auth.user_id, body=body)
+
+
+@router.patch("/{venue_id}", response_model=VenueResponse)
+def update_venue(
+ venue_id: UUID,
+ body: UpdateVenueRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+
+ return service.update_venue(db, venue_id=venue_id, owner_id=auth.user_id, body=body)
+
+
+@router.delete("/{venue_id}", response_model=DeleteResponse, status_code=200)
+def delete_venue(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+
+ service.delete_venue(db, venue_id=venue_id, owner_id=auth.user_id)
+ return DeleteResponse(id=venue_id)
+
+
+@router.post("/{venue_id}/submit", response_model=VenueResponse)
+def submit_venue(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.submit_venue(db, venue_id=venue_id, owner_id=auth.user_id)
+
+
+@router.put("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse])
+def bulk_update_availability(
+ venue_id: UUID,
+ body: BulkUpdateAvailabilityRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.bulk_update_availability(
+ db, venue_id, auth.user_id, body.availabilities
+ )
+
+
+@router.post(
+ "/{venue_id}/blocked-dates",
+ response_model=VenueBlockedDateResponse,
+ status_code=201,
+)
+def create_blocked_date(
+ venue_id: UUID,
+ body: CreateBlockedDateRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.create_blocked_date(db, venue_id, auth.user_id, body)
+
+
+@router.delete(
+ "/{venue_id}/blocked-dates/{blocked_id}",
+ response_model=DeleteResponse,
+ status_code=200,
+)
+def delete_blocked_date(
+ venue_id: UUID,
+ blocked_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ service.delete_blocked_date(db, venue_id, blocked_id, auth.user_id)
+ return DeleteResponse(id=blocked_id, message="Blocked date removed successfully")
+
+
+@router.get("/{venue_id}/pricing-rules", response_model=list[VenuePricingRuleResponse])
+def list_pricing_rules(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.list_pricing_rules(db, venue_id, auth.user_id)
+
+
+@router.post(
+ "/{venue_id}/pricing-rules",
+ response_model=VenuePricingRuleResponse,
+ status_code=201,
+)
+def create_pricing_rule(
+ venue_id: UUID,
+ body: CreatePricingRuleRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.create_pricing_rule(db, venue_id, auth.user_id, body)
+
+
+@router.patch(
+ "/{venue_id}/pricing-rules/{rule_id}", response_model=VenuePricingRuleResponse
+)
+def update_pricing_rule(
+ venue_id: UUID,
+ rule_id: UUID,
+ body: UpdatePricingRuleRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.update_pricing_rule(db, venue_id, rule_id, auth.user_id, body)
+
+
+@router.delete(
+ "/{venue_id}/pricing-rules/{rule_id}",
+ response_model=DeleteResponse,
+ status_code=200,
+)
+def delete_pricing_rule(
+ venue_id: UUID,
+ rule_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ service.delete_pricing_rule(db, venue_id, rule_id, auth.user_id)
+ return DeleteResponse(id=rule_id, message="Pricing rule removed successfully")
+
+
+@router.get("/{venue_id}/pricing-preview", response_model=PricingPreviewResponse)
+def get_owner_pricing_preview(
+ venue_id: UUID,
+ starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"),
+ ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"),
+ booking_type: BookingType = Query(..., description="full_day or time_slot"),
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ starts_dt = parse_timezone_datetime(starts_at, "starts_at")
+ ends_dt = parse_timezone_datetime(ends_at, "ends_at")
+ return service.get_owner_pricing_preview(
+ db, venue_id, auth.user_id, starts_dt, ends_dt, booking_type
+ )
+
+
+@router.put(
+ "/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse
+)
+def put_venue_cancellation_policy(
+ venue_id: UUID,
+ body: UpdateCancellationPolicyRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.put_venue_cancellation_policy(db, venue_id, auth.user_id, body)
+
+
+@router.put("/{venue_id}/amenities", response_model=list[AmenityResponse])
+def update_venue_amenities(
+ venue_id: UUID,
+ body: UpdateVenueAmenitiesRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.update_venue_amenities(db, venue_id, auth.user_id, body)
+
+
+@router.post("/{venue_id}/photos", response_model=VenuePhotoResponse, status_code=201)
+async def add_venue_photo(
+ venue_id: UUID,
+ file: UploadFile = File(...),
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ if not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="File must be an image")
+
+ file_bytes = await file.read()
+ return service.add_venue_photo(db, venue_id, auth.user_id, file_bytes)
+
+
+@router.put("/{venue_id}/photos/bulk-update", response_model=list[VenuePhotoResponse])
+def bulk_update_venue_photos(
+ venue_id: UUID,
+ body: BulkUpdateVenuePhotosRequest,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return service.bulk_update_venue_photos(db, venue_id, auth.user_id, body)
+
+
+@router.delete(
+ "/{venue_id}/photos/{photo_id}", response_model=DeleteResponse, status_code=200
+)
+def delete_venue_photo(
+ venue_id: UUID,
+ photo_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ service.delete_venue_photo(db, venue_id, photo_id, auth.user_id)
+ return DeleteResponse(id=photo_id, message="Photo deleted successfully")
+
+
+@router.get("/{venue_id}/bookings", response_model=list[BookingOut])
+def list_venue_bookings(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return booking_service.list_venue_bookings(db, venue_id, auth.user_id)
+
+
+@router.get("/{venue_id}/bookings/pending", response_model=list[BookingOut])
+def list_pending_venue_bookings(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_owner),
+ db: Session = Depends(get_db),
+):
+ return booking_service.list_venue_bookings(
+ db,
+ venue_id,
+ auth.user_id,
+ pending_only=True,
+ )
+
+
+# User routes
+
+
+@router.get("/likes", response_model=list[UUID])
+def get_liked_venue_ids(
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ return service.get_liked_venue_ids(db, auth.user_id)
+
+
+@router.post("/{venue_id}/like", response_model=dict)
+def toggle_venue_like(
+ venue_id: UUID,
+ auth: AuthContext = Depends(require_auth),
+ db: Session = Depends(get_db),
+):
+ is_liked = service.toggle_venue_like(db, venue_id, auth.user_id)
+ return {"is_liked": is_liked}
+
+
+# Public routes
+
+
+@router.get("/categories", response_model=list[VenueCategoryResponse])
+def get_venue_categories(db: Session = Depends(get_db)):
+ return service.get_venue_categories(db)
+
+
+@router.get("/amenities", response_model=list[AmenityResponse])
+def get_platform_amenities(db: Session = Depends(get_db)):
+ return service.get_platform_amenities(db)
+
+
+@router.get("/{identifier}", response_model=VenueResponse)
+def get_venue(
+ identifier: str,
+ auth: AuthContext | None = Depends(get_current_user_optional),
+ db: Session = Depends(get_db),
+):
+
+ venue = service.get_venue(db, identifier, user_id=auth.user_id if auth else None)
+ return service._enrich_venue_with_ratings(db, venue)
+
+
+@router.get("/{venue_id}/pricing", response_model=PricingPreviewResponse)
+def get_pricing_preview(
+ venue_id: UUID,
+ starts_at: str = Query(..., description="ISO 8601 datetime with timezone offset"),
+ ends_at: str = Query(..., description="ISO 8601 datetime with timezone offset"),
+ booking_type: BookingType = Query(..., description="full_day or time_slot"),
+ db: Session = Depends(get_db),
+):
+ starts_dt = parse_timezone_datetime(starts_at, "starts_at")
+ ends_dt = parse_timezone_datetime(ends_at, "ends_at")
+ return service.get_pricing_preview(db, venue_id, starts_dt, ends_dt, booking_type)
+
+
+@router.get("/{venue_id}/availability", response_model=list[VenueAvailabilityResponse])
+def get_venue_availability(
+ venue_id: UUID,
+ db: Session = Depends(get_db),
+):
+ return service.get_venue_availability(db, venue_id)
+
+
+@router.get(
+ "/{venue_id}/blocked-dates", response_model=list[PublicVenueBlockedDateResponse]
+)
+def get_venue_blocked_dates(
+ venue_id: UUID,
+ db: Session = Depends(get_db),
+):
+ return service.get_venue_blocked_dates(db, venue_id)
+
+
+@router.get(
+ "/{venue_id}/cancellation-policy", response_model=CancellationPolicyResponse
+)
+def get_venue_cancellation_policy(
+ venue_id: UUID,
+ db: Session = Depends(get_db),
+):
+ return service.get_venue_cancellation_policy(db, venue_id)
diff --git a/apps/api/app/modules/venue/schemas.py b/apps/api/app/modules/venue/schemas.py
new file mode 100644
index 000000000..d579beba6
--- /dev/null
+++ b/apps/api/app/modules/venue/schemas.py
@@ -0,0 +1,707 @@
+from pydantic import BaseModel, field_validator, Field
+from uuid import UUID
+from datetime import datetime, date, time
+from typing import Optional
+from decimal import Decimal
+from enum import Enum
+
+
+
+class BookingType(str, Enum):
+ full_day = "full_day"
+ time_slot = "time_slot"
+
+
+class PricingMode(str, Enum):
+ flat = "flat"
+ hourly = "hourly"
+ mixed = "mixed"
+
+
+class VenueStatus(str, Enum):
+ draft = "draft"
+ pending_approval = "pending_approval"
+ approved = "approved"
+ rejected = "rejected"
+ suspended = "suspended"
+
+
+class BookingMode(str, Enum):
+ MANUAL = "MANUAL"
+ INSTANT = "INSTANT"
+
+
+
+class VenueCategoryResponse(BaseModel):
+ id: UUID
+ slug: str
+ label: str
+ icon: Optional[str] = None
+ banner_image: Optional[str] = None
+ is_active: bool
+ sort_order: int
+
+ model_config = {"from_attributes": True}
+
+
+class VenuePhotoResponse(BaseModel):
+ id: UUID
+ venue_id: UUID
+ image_url: str
+ sort_order: int
+ is_cover: bool
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class AmenityResponse(BaseModel):
+ id: UUID
+ name: str
+ icon: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+
+class CancellationPolicyResponse(BaseModel):
+ tier_1_hours: Optional[int] = None
+ tier_1_refund_pct: Optional[Decimal] = None
+ tier_2_hours: Optional[int] = None
+ tier_2_refund_pct: Optional[Decimal] = None
+ tier_3_hours: Optional[int] = None
+ tier_3_refund_pct: Optional[Decimal] = None
+ no_show_refund_pct: Decimal
+ platform_fee_refundable: bool
+ notes: Optional[str] = None
+
+ model_config = {"from_attributes": True}
+
+class UpdateCancellationPolicyRequest(BaseModel):
+ tier_1_hours: Optional[int] = Field(default=None, gt=0)
+ tier_1_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100)
+ tier_2_hours: Optional[int] = Field(default=None, gt=0)
+ tier_2_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100)
+ tier_3_hours: Optional[int] = Field(default=None, gt=0)
+ tier_3_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100)
+ no_show_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
+ platform_fee_refundable: bool = False
+ notes: Optional[str] = None
+
+ def model_post_init(self, __context) -> None:
+ if (self.tier_1_hours is None) != (self.tier_1_refund_pct is None):
+ raise ValueError("tier_1_hours and tier_1_refund_pct must be both set or both null")
+ if (self.tier_2_hours is None) != (self.tier_2_refund_pct is None):
+ raise ValueError("tier_2_hours and tier_2_refund_pct must be both set or both null")
+ if (self.tier_3_hours is None) != (self.tier_3_refund_pct is None):
+ raise ValueError("tier_3_hours and tier_3_refund_pct must be both set or both null")
+
+ if self.tier_1_hours is not None and self.tier_2_hours is not None:
+ if self.tier_1_hours <= self.tier_2_hours:
+ raise ValueError("tier_1_hours must be strictly greater than tier_2_hours")
+ if self.tier_2_hours is not None and self.tier_3_hours is not None:
+ if self.tier_2_hours <= self.tier_3_hours:
+ raise ValueError("tier_2_hours must be strictly greater than tier_3_hours")
+
+
+class UpdateVenueAmenitiesRequest(BaseModel):
+ amenity_ids: list[UUID]
+
+
+class UpdateVenuePhotoItem(BaseModel):
+ photo_id: UUID
+ sort_order: int
+ is_cover: bool
+
+class BulkUpdateVenuePhotosRequest(BaseModel):
+ photos: list[UpdateVenuePhotoItem]
+
+
+from typing import Any
+from pydantic import model_validator
+
+class VenueListResponse(BaseModel):
+ id: UUID
+ name: str
+ slug: Optional[str] = None
+ city: str
+ max_capacity: int
+ status: VenueStatus
+ is_active: bool
+ category_name: str
+ cover_photo_url: Optional[str] = None
+ last_completed_step: Optional[int] = Field(default=0, ge=0)
+
+ @model_validator(mode="before")
+ @classmethod
+ def flatten_nested(cls, data: Any) -> Any:
+ if isinstance(data, dict):
+ return data
+
+ result = {
+ "id": data.id,
+ "name": data.name,
+ "slug": data.slug,
+ "city": data.city,
+ "max_capacity": data.max_capacity,
+ "status": data.status,
+ "is_active": data.is_active,
+ "last_completed_step": data.last_completed_step,
+ }
+
+ category = getattr(data, 'category', None)
+ result["category_name"] = category.label if category else "Uncategorized"
+
+ photos = getattr(data, 'photos', [])
+ if photos:
+ cover = next((p for p in photos if p.is_cover), photos[0])
+ result["cover_photo_url"] = cover.image_url
+ else:
+ result["cover_photo_url"] = None
+
+ return result
+
+class VenueStatsResponse(BaseModel):
+ active_bookings: int
+ revenue_this_month_paise: int
+
+class VenueResponse(BaseModel):
+ id: UUID
+ owner_id: UUID
+
+
+ name: str
+ slug: Optional[str] = None
+ description: Optional[str] = None
+ category: VenueCategoryResponse
+
+
+ address_line1: str
+ address_line2: Optional[str] = None
+ city: str
+ state: str
+ country: str
+ postal_code: Optional[str] = None
+ latitude: Optional[Decimal] = None
+ longitude: Optional[Decimal] = None
+ timezone: str
+
+
+ min_capacity: Optional[int] = None
+ max_capacity: int
+
+
+ open_time: time
+ close_time: time
+ spans_next_day: bool
+
+
+ allowed_booking_types: list[BookingType]
+ min_booking_duration_minutes: int
+ max_booking_duration_minutes: int
+ slot_interval_minutes: int
+
+
+ pre_buffer_minutes: int
+ post_buffer_minutes: int
+
+
+ pricing_mode: PricingMode
+ starting_price_paise: Optional[int] = None
+ hourly_rate_paise: Optional[int] = None
+
+
+ platform_commission_pct: Decimal
+
+
+ advance_pct: Decimal
+ balance_due_days_before_event: int
+ owner_action_window_hours: int
+ overdue_advance_refund_pct: Decimal
+
+ min_price_pct: Decimal
+ max_price_pct: Decimal
+ display_price_min_paise: Optional[int] = None
+ display_price_max_paise: Optional[int] = None
+
+
+ status: VenueStatus
+ booking_mode: BookingMode = BookingMode.MANUAL
+ is_active: bool
+
+
+ created_at: datetime
+ updated_at: datetime
+
+ last_completed_step: int
+
+
+ photos: list[VenuePhotoResponse] = Field(default_factory=list)
+ amenities: list[AmenityResponse] = Field(default_factory=list)
+ cancellation_policy: Optional[CancellationPolicyResponse] = None
+ is_liked: bool = False
+ average_rating: Optional[float] = None
+ review_count: int = 0
+
+ model_config = {"from_attributes": True}
+
+
+class DeleteResponse(BaseModel):
+ id: UUID
+ deleted: bool = True
+ message: str = "Venue deleted successfully"
+
+
+class CreateVenueRequest(BaseModel):
+
+ name: str
+ description: Optional[str] = None
+ category_id: UUID
+
+
+ address_line1: str
+ address_line2: Optional[str] = None
+ city: str
+ state: str
+ country: str = "India"
+ postal_code: Optional[str] = None
+ latitude: Optional[Decimal] = None
+ longitude: Optional[Decimal] = None
+ timezone: str = "Asia/Kolkata"
+
+
+ min_capacity: Optional[int] = Field(default=None, gt=0)
+ max_capacity: int = Field(gt=0)
+
+
+ open_time: time
+ close_time: time
+ spans_next_day: bool = False
+
+
+ allowed_booking_types: list[BookingType] = Field(default_factory=lambda: [BookingType.full_day, BookingType.time_slot])
+ booking_mode: BookingMode = BookingMode.MANUAL
+ min_booking_duration_minutes: int = Field(default=60, gt=0)
+ max_booking_duration_minutes: int = Field(default=1440, gt=0)
+ slot_interval_minutes: int = Field(default=30, gt=0)
+
+
+ pre_buffer_minutes: int = Field(default=0, ge=0)
+ post_buffer_minutes: int = Field(default=0, ge=0)
+
+
+ pricing_mode: PricingMode = PricingMode.flat
+ starting_price_paise: Optional[int] = Field(default=None, ge=0)
+ hourly_rate_paise: Optional[int] = Field(default=None, ge=0)
+
+
+ advance_pct: Decimal = Field(default=Decimal("30.00"), gt=0, le=100)
+ balance_due_days_before_event: int = Field(default=7, gt=0)
+ owner_action_window_hours: int = Field(default=48, ge=24, le=72)
+ overdue_advance_refund_pct: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
+
+ min_price_pct: Decimal = Field(default=Decimal("50.00"), gt=0, le=100)
+ max_price_pct: Decimal = Field(default=Decimal("200.00"), ge=100, le=500)
+
+ cancellation_policy: Optional[UpdateCancellationPolicyRequest] = None
+ amenity_ids: Optional[list[UUID]] = None
+
+ last_completed_step: Optional[int] = Field(default=0, ge=0)
+
+ @field_validator("allowed_booking_types")
+ @classmethod
+ def validate_booking_types(cls, v: list[BookingType]) -> list[BookingType]:
+ if not v:
+ raise ValueError("allowed_booking_types cannot be empty")
+ if len(v) != len(set(v)):
+ raise ValueError("Duplicate booking types are not allowed")
+ return v
+
+ def model_post_init(self, __context) -> None:
+ has_full_day = BookingType.full_day in self.allowed_booking_types
+ has_time_slot = BookingType.time_slot in self.allowed_booking_types
+
+ if has_full_day and has_time_slot:
+ if self.pricing_mode != PricingMode.mixed:
+ raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed")
+ elif has_full_day:
+ if self.pricing_mode != PricingMode.flat:
+ raise ValueError("pricing_mode must be 'flat' when only full_day is allowed")
+ elif has_time_slot:
+ if self.pricing_mode != PricingMode.hourly:
+ raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed")
+
+ if self.pricing_mode == PricingMode.flat:
+ if self.starting_price_paise is None:
+ raise ValueError("starting_price_paise is required when pricing_mode is 'flat'")
+ if self.hourly_rate_paise is not None:
+ raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'")
+ elif self.pricing_mode == PricingMode.hourly:
+ if self.hourly_rate_paise is None:
+ raise ValueError("hourly_rate_paise is required when pricing_mode is 'hourly'")
+ if self.starting_price_paise is not None:
+ raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'")
+ elif self.pricing_mode == PricingMode.mixed:
+ if self.starting_price_paise is None or self.hourly_rate_paise is None:
+ raise ValueError("Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'")
+
+
+ if (
+ self.min_capacity is not None
+ and self.min_capacity > self.max_capacity
+ ):
+ raise ValueError("min_capacity cannot exceed max_capacity")
+
+
+ if self.min_booking_duration_minutes > self.max_booking_duration_minutes:
+ raise ValueError(
+ "min_booking_duration_minutes cannot exceed max_booking_duration_minutes"
+ )
+
+ if not self.spans_next_day and self.close_time <= self.open_time:
+ raise ValueError("close_time must be after open_time unless spans_next_day is true")
+
+ if self.min_price_pct > self.max_price_pct:
+ raise ValueError("min_price_pct cannot exceed max_price_pct")
+
+
+class UpdateVenueRequest(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ category_id: Optional[UUID] = None
+
+ address_line1: Optional[str] = None
+ address_line2: Optional[str] = None
+ city: Optional[str] = None
+ state: Optional[str] = None
+ country: Optional[str] = None
+ postal_code: Optional[str] = None
+ latitude: Optional[Decimal] = None
+ longitude: Optional[Decimal] = None
+ timezone: Optional[str] = None
+
+ min_capacity: Optional[int] = Field(default=None, gt=0)
+ max_capacity: Optional[int] = Field(default=None, gt=0)
+
+ open_time: Optional[time] = None
+ close_time: Optional[time] = None
+ spans_next_day: Optional[bool] = None
+
+ allowed_booking_types: Optional[list[BookingType]] = None
+ booking_mode: Optional[BookingMode] = None
+ min_booking_duration_minutes: Optional[int] = Field(default=None, gt=0)
+ max_booking_duration_minutes: Optional[int] = Field(default=None, gt=0)
+ slot_interval_minutes: Optional[int] = Field(default=None, gt=0)
+
+ pre_buffer_minutes: Optional[int] = Field(default=None, ge=0)
+ post_buffer_minutes: Optional[int] = Field(default=None, ge=0)
+
+ pricing_mode: Optional[PricingMode] = None
+ starting_price_paise: Optional[int] = Field(default=None, ge=0)
+ hourly_rate_paise: Optional[int] = Field(default=None, ge=0)
+
+ advance_pct: Optional[Decimal] = Field(default=None, gt=0, le=100)
+ balance_due_days_before_event: Optional[int] = Field(default=None, gt=0)
+ owner_action_window_hours: Optional[int] = Field(default=None, ge=24, le=72)
+ overdue_advance_refund_pct: Optional[Decimal] = Field(default=None, ge=0, le=100)
+
+ min_price_pct: Optional[Decimal] = Field(default=None, gt=0, le=100)
+ max_price_pct: Optional[Decimal] = Field(default=None, ge=100, le=500)
+
+ last_completed_step: Optional[int] = None
+
+ @field_validator("allowed_booking_types")
+ @classmethod
+ def validate_booking_types(cls, v: Optional[list[BookingType]]) -> Optional[list[BookingType]]:
+ if v is not None:
+ if not v:
+ raise ValueError("allowed_booking_types cannot be empty if provided")
+ if len(v) != len(set(v)):
+ raise ValueError("Duplicate booking types are not allowed")
+ return v
+
+ def model_post_init(self, __context) -> None:
+ if self.allowed_booking_types is not None and self.pricing_mode is not None:
+ has_full_day = BookingType.full_day in self.allowed_booking_types
+ has_time_slot = BookingType.time_slot in self.allowed_booking_types
+
+ if has_full_day and has_time_slot:
+ if self.pricing_mode != PricingMode.mixed:
+ raise ValueError("pricing_mode must be 'mixed' when both full_day and time_slot are allowed")
+ elif has_full_day:
+ if self.pricing_mode != PricingMode.flat:
+ raise ValueError("pricing_mode must be 'flat' when only full_day is allowed")
+ elif has_time_slot:
+ if self.pricing_mode != PricingMode.hourly:
+ raise ValueError("pricing_mode must be 'hourly' when only time_slot is allowed")
+
+ if self.pricing_mode == PricingMode.flat and self.hourly_rate_paise is not None:
+ raise ValueError("hourly_rate_paise must be null when pricing_mode is 'flat'")
+ if self.pricing_mode == PricingMode.hourly and self.starting_price_paise is not None:
+ raise ValueError("starting_price_paise must be null when pricing_mode is 'hourly'")
+
+ if (
+ self.min_capacity is not None
+ and self.max_capacity is not None
+ and self.min_capacity > self.max_capacity
+ ):
+ raise ValueError("min_capacity cannot exceed max_capacity")
+
+ if (
+ self.min_booking_duration_minutes is not None
+ and self.max_booking_duration_minutes is not None
+ and self.min_booking_duration_minutes > self.max_booking_duration_minutes
+ ):
+ raise ValueError(
+ "min_booking_duration_minutes cannot exceed max_booking_duration_minutes"
+ )
+
+ if self.open_time is not None and self.close_time is not None:
+ spans_next = self.spans_next_day if self.spans_next_day is not None else False
+ if not spans_next and self.close_time <= self.open_time:
+ raise ValueError("close_time must be after open_time unless spans_next_day is true")
+
+ if (
+ self.min_price_pct is not None
+ and self.max_price_pct is not None
+ and self.min_price_pct > self.max_price_pct
+ ):
+ raise ValueError("min_price_pct cannot exceed max_price_pct")
+
+
+class PricingDisplay(BaseModel):
+ quoted_price: str
+ advance_due: str
+ balance_due: str
+ platform_fee: str
+ owner_payout: str
+
+
+class PricingBreakdownItem(BaseModel):
+ period_date: date
+ start_time: Optional[time] = None
+ end_time: Optional[time] = None
+ base_paise: int
+ applied_rule_id: Optional[UUID] = None
+ applied_rule_name: Optional[str] = None
+ clamped: bool
+ final_paise: int
+
+
+class PricingPreviewResponse(BaseModel):
+ pricing_mode: PricingMode
+ quoted_price_paise: int
+ platform_commission_pct: float
+ platform_fee_paise: int
+ owner_payout_paise: int
+ advance_pct: float
+ advance_due_paise: int
+ balance_due_paise: int
+ display: PricingDisplay
+ breakdown: list[PricingBreakdownItem] = Field(default_factory=list)
+ clamped: bool = False
+
+
+# ─── Search Result (used by search module) ────────────────────────────────────
+
+class VenueSearchResult(BaseModel):
+ id: UUID
+ name: str
+ slug: Optional[str] = None
+ category: VenueCategoryResponse
+ city: str
+ state: str
+ max_capacity: int
+ pricing_mode: PricingMode
+ starting_price_paise: Optional[int] = None
+ hourly_rate_paise: Optional[int] = None
+ cover_photo_url: Optional[str] = None
+ status: VenueStatus
+ is_liked: bool = False
+
+ model_config = {"from_attributes": True}
+
+
+class VenueAvailabilityResponse(BaseModel):
+ day_of_week: int = Field(ge=0, le=6)
+ is_available: bool
+ opens_at: Optional[time] = None
+ closes_at: Optional[time] = None
+ spans_next_day: bool
+
+ model_config = {"from_attributes": True}
+
+class VenueAvailabilityUpdate(BaseModel):
+ day_of_week: int = Field(ge=0, le=6)
+ is_available: bool
+ opens_at: Optional[time] = None
+ closes_at: Optional[time] = None
+ spans_next_day: bool = False
+
+ def model_post_init(self, __context) -> None:
+ if self.is_available:
+ if self.opens_at is None or self.closes_at is None:
+ raise ValueError("opens_at and closes_at are required when is_available is true")
+ if not self.spans_next_day and self.closes_at <= self.opens_at:
+ raise ValueError("closes_at must be after opens_at unless spans_next_day is true")
+
+class BulkUpdateAvailabilityRequest(BaseModel):
+ availabilities: list[VenueAvailabilityUpdate]
+
+ @field_validator("availabilities")
+ @classmethod
+ def validate_unique_days(cls, v: list[VenueAvailabilityUpdate]) -> list[VenueAvailabilityUpdate]:
+ days = [item.day_of_week for item in v]
+ if len(days) != len(set(days)):
+ raise ValueError("Duplicate day_of_week entries are not allowed")
+ return v
+
+
+class PublicVenueBlockedDateResponse(BaseModel):
+ id: UUID
+ venue_id: UUID
+ starts_at: datetime
+ ends_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class VenueBlockedDateResponse(BaseModel):
+ id: UUID
+ venue_id: UUID
+ starts_at: datetime
+ ends_at: datetime
+ reason: Optional[str] = None
+ blocked_by: UUID
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+class CreateBlockedDateRequest(BaseModel):
+ starts_at: datetime
+ ends_at: datetime
+ reason: Optional[str] = None
+
+ def model_post_init(self, __context) -> None:
+ if self.ends_at <= self.starts_at:
+ raise ValueError("ends_at must be strictly after starts_at")
+
+
+class PricingRuleAdjustmentType(str, Enum):
+ multiplier = "multiplier"
+ fixed_delta = "fixed_delta"
+ override = "override"
+
+
+class PricingRuleAppliesTo(str, Enum):
+ full_day = "full_day"
+ time_slot = "time_slot"
+ both = "both"
+
+
+MAX_ACTIVE_PRICING_RULES_PER_VENUE = 20
+
+MIN_VENUE_PHOTOS = 3
+
+
+class VenuePricingRuleResponse(BaseModel):
+ id: UUID
+ venue_id: UUID
+ name: str
+ days_of_week: Optional[list[int]] = None
+ start_date: Optional[date] = None
+ end_date: Optional[date] = None
+ start_time: Optional[time] = None
+ end_time: Optional[time] = None
+ adjustment_type: PricingRuleAdjustmentType
+ multiplier: Optional[Decimal] = None
+ amount_paise: Optional[int] = None
+ applies_to: PricingRuleAppliesTo
+ priority: int
+ source: str
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
+ exceeds_bounds: bool = False
+
+ model_config = {"from_attributes": True}
+
+
+class CreatePricingRuleRequest(BaseModel):
+ name: str = Field(min_length=1)
+ days_of_week: Optional[list[int]] = None
+ start_date: Optional[date] = None
+ end_date: Optional[date] = None
+ start_time: Optional[time] = None
+ end_time: Optional[time] = None
+ adjustment_type: PricingRuleAdjustmentType = PricingRuleAdjustmentType.multiplier
+ multiplier: Optional[Decimal] = Field(default=None, gt=0)
+ amount_paise: Optional[int] = None
+ applies_to: PricingRuleAppliesTo = PricingRuleAppliesTo.both
+ priority: int = 0
+ is_active: bool = True
+
+ def model_post_init(self, __context) -> None:
+ if self.days_of_week is not None:
+ if not self.days_of_week:
+ raise ValueError("days_of_week cannot be empty if provided")
+ if any(d < 0 or d > 6 for d in self.days_of_week):
+ raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)")
+
+ if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date:
+ raise ValueError("start_date cannot be after end_date")
+
+ if self.adjustment_type == PricingRuleAdjustmentType.multiplier:
+ if self.multiplier is None:
+ raise ValueError("multiplier is required when adjustment_type is 'multiplier'")
+ if self.amount_paise is not None:
+ raise ValueError("amount_paise must be null when adjustment_type is 'multiplier'")
+ else:
+ if self.amount_paise is None:
+ raise ValueError("amount_paise is required when adjustment_type is 'fixed_delta' or 'override'")
+ if self.multiplier is not None:
+ raise ValueError("multiplier must be null when adjustment_type is not 'multiplier'")
+ if self.adjustment_type == PricingRuleAdjustmentType.override and self.amount_paise < 0:
+ raise ValueError("amount_paise must be >= 0 when adjustment_type is 'override'")
+
+
+class UpdatePricingRuleRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1)
+ days_of_week: Optional[list[int]] = None
+ start_date: Optional[date] = None
+ end_date: Optional[date] = None
+ start_time: Optional[time] = None
+ end_time: Optional[time] = None
+ adjustment_type: Optional[PricingRuleAdjustmentType] = None
+ multiplier: Optional[Decimal] = Field(default=None, gt=0)
+ amount_paise: Optional[int] = None
+ applies_to: Optional[PricingRuleAppliesTo] = None
+ priority: Optional[int] = None
+ is_active: Optional[bool] = None
+
+ def model_post_init(self, __context) -> None:
+ if self.days_of_week is not None:
+ if not self.days_of_week:
+ raise ValueError("days_of_week cannot be empty if provided")
+ if any(d < 0 or d > 6 for d in self.days_of_week):
+ raise ValueError("days_of_week values must be between 0 (Mon) and 6 (Sun)")
+
+ if self.start_date is not None and self.end_date is not None and self.start_date > self.end_date:
+ raise ValueError("start_date cannot be after end_date")
+
+
+class PricingQuote(BaseModel):
+ quoted_price_paise: int
+
+ platform_commission_pct: float
+ platform_fee_paise: int
+
+ owner_payout_paise: int
+
+ advance_pct: float
+ advance_due_paise: int
+ balance_due_paise: int
+
+ pricing_mode: str
+
+ breakdown: list[PricingBreakdownItem] = Field(default_factory=list)
+ clamped: bool = False
diff --git a/apps/api/app/modules/venue/service.py b/apps/api/app/modules/venue/service.py
new file mode 100644
index 000000000..9fa2807b5
--- /dev/null
+++ b/apps/api/app/modules/venue/service.py
@@ -0,0 +1,1328 @@
+import re
+import uuid
+from datetime import datetime, timedelta, timezone
+from decimal import Decimal, ROUND_HALF_EVEN
+from uuid import UUID
+
+from sqlalchemy.orm import Session, joinedload, selectinload
+
+from app.core.exceptions import NotFoundError, ForbiddenError, ConflictError
+from app.modules.venue.models import (
+ Venue,
+ VenueCategory,
+ VenueStatus,
+ VenueAvailability,
+ VenueBlockedDate,
+ VenueCancellationPolicy,
+ VenueAmenity,
+ Amenity,
+ VenuePhoto,
+ VenuePricingRule,
+ VenueLike,
+)
+from app.modules.venue.schemas import (
+ CreateVenueRequest,
+ UpdateVenueRequest,
+ PricingPreviewResponse,
+ PricingDisplay,
+ PricingBreakdownItem,
+ PricingQuote,
+ VenueAvailabilityUpdate,
+ CreateBlockedDateRequest,
+ UpdateCancellationPolicyRequest,
+ UpdateVenueAmenitiesRequest,
+ BulkUpdateVenuePhotosRequest,
+ VenuePricingRuleResponse,
+ CreatePricingRuleRequest,
+ UpdatePricingRuleRequest,
+ MAX_ACTIVE_PRICING_RULES_PER_VENUE,
+ MIN_VENUE_PHOTOS,
+)
+from app.modules.venue import pricing_engine
+from app.modules.booking.models import BookingType, Booking, BookingStatus
+from app.modules.payment.models import LedgerEntry
+from sqlalchemy import func
+from app.core.storage import upload_image_to_cloudinary, delete_image_from_cloudinary
+
+# Default platform commission
+
+DEFAULT_PLATFORM_COMMISSION_PCT = Decimal("10.00")
+
+
+# Internal helpers
+
+
+def _get_venue_or_404(db: Session, venue_id: str | UUID) -> Venue:
+ try:
+ vid = UUID(str(venue_id))
+ venue = (
+ db.query(Venue)
+ .filter(
+ Venue.id == vid,
+ Venue.deleted_at.is_(None),
+ )
+ .first()
+ )
+ except ValueError:
+ venue = (
+ db.query(Venue)
+ .filter(
+ Venue.slug == str(venue_id),
+ Venue.deleted_at.is_(None),
+ )
+ .first()
+ )
+
+ if not venue:
+ raise NotFoundError("Venue not found")
+ return venue
+
+
+# Acquire exclusive write lock on Venue to serialize slot check and creation
+def _get_active_venue_or_404(
+ db: Session,
+ venue_id: str | UUID,
+ *,
+ for_update: bool = False,
+) -> Venue:
+ query = db.query(Venue).filter(
+ Venue.status == VenueStatus.approved,
+ Venue.is_active.is_(True),
+ Venue.deleted_at.is_(None),
+ )
+
+ try:
+ vid = UUID(str(venue_id))
+ query = query.filter(Venue.id == vid)
+ except ValueError:
+ query = query.filter(Venue.slug == str(venue_id))
+
+ if for_update:
+ query = query.with_for_update()
+
+ venue = query.first()
+
+ if not venue:
+ raise NotFoundError("Venue not found")
+
+ return venue
+
+
+def _assert_owner(venue: Venue, owner_id: UUID) -> None:
+ if venue.owner_id != owner_id:
+ raise ForbiddenError("You do not own this venue")
+
+
+def _banker_round(value: Decimal) -> int:
+ """Banker's rounding to nearest integer for financial precision."""
+ return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))
+
+
+def _format_inr(paise: int) -> str:
+ rupees = paise / 100
+ return f"₹{rupees:,.0f}"
+
+
+def _generate_slug(db: Session, name: str) -> str:
+ base_slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
+ if not base_slug:
+ base_slug = "venue"
+
+ slug = base_slug
+ counter = 1
+ while db.query(Venue).filter(Venue.slug == slug).first():
+ slug = f"{base_slug}-{counter}"
+ counter += 1
+ return slug
+
+
+def _enrich_venue_with_ratings(db: Session, venue: Venue) -> Venue:
+ """
+ Attach average_rating and review_count to a venue object.
+ These are calculated from public (non-hidden, non-deleted) reviews.
+ """
+ from app.modules.review.models import VenueReview
+
+ reviews = (
+ db.query(VenueReview)
+ .filter(
+ VenueReview.venue_id == venue.id,
+ VenueReview.deleted_at.is_(None),
+ VenueReview.is_hidden.is_(False),
+ )
+ .all()
+ )
+
+ if not reviews:
+ venue.average_rating = None
+ venue.review_count = 0
+ else:
+ total_rating = sum(r.rating for r in reviews)
+ venue.average_rating = round(total_rating / len(reviews), 2)
+ venue.review_count = len(reviews)
+
+ return venue
+
+
+# Public service functions
+
+
+def get_venue_categories(db: Session) -> list[VenueCategory]:
+ return (
+ db.query(VenueCategory)
+ .filter(VenueCategory.is_active.is_(True), VenueCategory.deleted_at.is_(None))
+ .order_by(VenueCategory.sort_order.asc(), VenueCategory.label.asc())
+ .all()
+ )
+
+
+def get_platform_amenities(db: Session) -> list[Amenity]:
+ return (
+ db.query(Amenity)
+ .filter(Amenity.deleted_at.is_(None))
+ .order_by(Amenity.name.asc())
+ .all()
+ )
+
+
+def get_venue(db: Session, identifier: str, user_id: UUID | None = None) -> Venue:
+ try:
+ venue_id = UUID(identifier)
+ venue = _get_active_venue_or_404(db, venue_id)
+ except ValueError:
+ venue = (
+ db.query(Venue)
+ .filter(
+ Venue.slug == identifier,
+ Venue.status == VenueStatus.approved,
+ Venue.is_active.is_(True),
+ Venue.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not venue:
+ raise NotFoundError("Venue not found")
+ return venue
+
+
+def toggle_venue_like(db: Session, venue_id: UUID, user_id: UUID) -> bool:
+ venue = _get_active_venue_or_404(db, venue_id)
+ like = (
+ db.query(VenueLike)
+ .filter(VenueLike.user_id == user_id, VenueLike.venue_id == venue.id)
+ .first()
+ )
+
+ if like:
+ db.delete(like)
+ db.commit()
+ return False
+ else:
+ new_like = VenueLike(user_id=user_id, venue_id=venue.id)
+ db.add(new_like)
+ db.commit()
+ return True
+
+
+def get_liked_venue_ids(db: Session, user_id: UUID) -> list[UUID]:
+ likes = db.query(VenueLike).filter(VenueLike.user_id == user_id).all()
+ return [like.venue_id for like in likes]
+
+
+def get_pricing_preview(
+ db: Session,
+ venue_id: UUID,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: BookingType,
+) -> PricingPreviewResponse:
+ venue = _get_active_venue_or_404(db, venue_id)
+ return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type)
+
+
+def get_owner_pricing_preview(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: BookingType,
+) -> PricingPreviewResponse:
+ """Owner-facing dry run: same engine as the public quote, but usable on
+ draft/pending venues too (ownership-gated instead of approval-gated)."""
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+ return _compute_pricing_preview(db, venue, starts_at, ends_at, booking_type)
+
+
+def _compute_pricing_preview(
+ db: Session,
+ venue: Venue,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: BookingType,
+) -> PricingPreviewResponse:
+ if ends_at <= starts_at:
+ raise ConflictError("ends_at must be after starts_at")
+
+ active_rules = [
+ r
+ for r in db.query(VenuePricingRule)
+ .filter(
+ VenuePricingRule.venue_id == venue.id,
+ VenuePricingRule.deleted_at.is_(None),
+ VenuePricingRule.is_active.is_(True),
+ )
+ .all()
+ ]
+ applies_to = "full_day" if booking_type == BookingType.full_day else "time_slot"
+ has_matching_rules = any(r.applies_to in (applies_to, "both") for r in active_rules)
+
+ breakdown: list[PricingBreakdownItem] = []
+ any_clamped = False
+
+ # ── Pricing Logic ─────────────────────────────────────
+ if booking_type == BookingType.full_day:
+ base = venue.starting_price_paise or 0
+
+ if not has_matching_rules:
+ days = (ends_at.date() - starts_at.date()).days + 1
+ quoted_price_paise = base * days
+ else:
+ quoted_price_paise = 0
+ current_date = starts_at.date()
+ end_date = ends_at.date()
+ while current_date <= end_date:
+ unit = pricing_engine.price_unit(
+ active_rules,
+ base_paise=base,
+ target_date=current_date,
+ target_time=None,
+ applies_to="full_day",
+ min_price_pct=Decimal(str(venue.min_price_pct)),
+ max_price_pct=Decimal(str(venue.max_price_pct)),
+ )
+ quoted_price_paise += unit.final_paise
+ any_clamped = any_clamped or unit.clamped
+ breakdown.append(
+ PricingBreakdownItem(
+ period_date=current_date,
+ base_paise=unit.base_paise,
+ applied_rule_id=unit.applied_rule_id,
+ applied_rule_name=unit.applied_rule_name,
+ clamped=unit.clamped,
+ final_paise=unit.final_paise,
+ )
+ )
+ current_date += timedelta(days=1)
+ used_mode = "flat" # full_day always uses base price (per day)
+
+ elif booking_type == BookingType.time_slot:
+ hourly_rate = venue.hourly_rate_paise or 0
+
+ if not has_matching_rules:
+ duration_seconds = (ends_at - starts_at).total_seconds()
+ duration_hours = Decimal(str(duration_seconds)) / Decimal("3600")
+ quoted_price_paise = _banker_round(
+ Decimal(str(hourly_rate)) * duration_hours
+ )
+ else:
+ interval_minutes = venue.slot_interval_minutes or 30
+ quoted_price_paise = 0
+ cursor = starts_at
+ while cursor < ends_at:
+ segment_end = min(cursor + timedelta(minutes=interval_minutes), ends_at)
+ segment_hours = Decimal(
+ str((segment_end - cursor).total_seconds())
+ ) / Decimal("3600")
+ segment_base = _banker_round(Decimal(str(hourly_rate)) * segment_hours)
+ unit = pricing_engine.price_unit(
+ active_rules,
+ base_paise=segment_base,
+ target_date=cursor.date(),
+ target_time=cursor.time(),
+ applies_to="time_slot",
+ min_price_pct=Decimal(str(venue.min_price_pct)),
+ max_price_pct=Decimal(str(venue.max_price_pct)),
+ )
+ quoted_price_paise += unit.final_paise
+ any_clamped = any_clamped or unit.clamped
+ breakdown.append(
+ PricingBreakdownItem(
+ period_date=cursor.date(),
+ start_time=cursor.time(),
+ end_time=segment_end.time(),
+ base_paise=unit.base_paise,
+ applied_rule_id=unit.applied_rule_id,
+ applied_rule_name=unit.applied_rule_name,
+ clamped=unit.clamped,
+ final_paise=unit.final_paise,
+ )
+ )
+ cursor = segment_end
+ used_mode = "hourly"
+
+ else:
+ raise ConflictError(f"Invalid booking_type: {booking_type}")
+
+ platform_fee_paise = _banker_round(
+ Decimal(str(quoted_price_paise))
+ * Decimal(str(venue.platform_commission_pct))
+ / Decimal("100")
+ )
+
+ owner_payout_paise = quoted_price_paise - platform_fee_paise
+
+ advance_due_paise = _banker_round(
+ Decimal(str(quoted_price_paise))
+ * Decimal(str(venue.advance_pct))
+ / Decimal("100")
+ )
+
+ balance_due_paise = quoted_price_paise - advance_due_paise
+
+ return PricingPreviewResponse(
+ pricing_mode=used_mode,
+ quoted_price_paise=quoted_price_paise,
+ platform_commission_pct=float(venue.platform_commission_pct),
+ platform_fee_paise=platform_fee_paise,
+ owner_payout_paise=owner_payout_paise,
+ advance_pct=float(venue.advance_pct),
+ advance_due_paise=advance_due_paise,
+ balance_due_paise=balance_due_paise,
+ display=PricingDisplay(
+ quoted_price=_format_inr(quoted_price_paise),
+ advance_due=_format_inr(advance_due_paise),
+ balance_due=_format_inr(balance_due_paise),
+ platform_fee=_format_inr(platform_fee_paise),
+ owner_payout=_format_inr(owner_payout_paise),
+ ),
+ breakdown=breakdown,
+ clamped=any_clamped,
+ )
+
+
+# Owner service functions
+
+
+def list_owner_venues(db: Session, owner_id: UUID) -> list[Venue]:
+
+ return (
+ db.query(Venue)
+ .options(
+ joinedload(Venue.category),
+ selectinload(Venue.photos),
+ selectinload(Venue.amenities),
+ joinedload(Venue.cancellation_policy),
+ )
+ .filter(
+ Venue.owner_id == owner_id,
+ Venue.deleted_at.is_(None),
+ )
+ .order_by(Venue.created_at.desc())
+ .all()
+ )
+
+
+def get_owner_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue:
+ venue = (
+ db.query(Venue)
+ .options(
+ joinedload(Venue.category),
+ selectinload(Venue.photos),
+ selectinload(Venue.amenities),
+ joinedload(Venue.cancellation_policy),
+ )
+ .filter(
+ Venue.id == venue_id,
+ Venue.owner_id == owner_id,
+ Venue.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not venue:
+ raise NotFoundError("Venue not found")
+ return venue
+
+
+def _get_category_or_400(db: Session, category_id: UUID) -> VenueCategory:
+ cat = (
+ db.query(VenueCategory)
+ .filter(
+ VenueCategory.id == category_id,
+ VenueCategory.is_active.is_(True),
+ VenueCategory.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not cat:
+ raise ConflictError("Invalid or inactive category")
+ return cat
+
+
+def create_venue(db: Session, owner_id: UUID, body: CreateVenueRequest) -> Venue:
+
+ _get_category_or_400(db, body.category_id)
+
+ venue = Venue(
+ id=uuid.uuid4(),
+ owner_id=owner_id,
+ name=body.name,
+ slug=_generate_slug(db, body.name),
+ description=body.description,
+ category_id=body.category_id,
+ address_line1=body.address_line1,
+ address_line2=body.address_line2,
+ city=body.city,
+ state=body.state,
+ country=body.country,
+ postal_code=body.postal_code,
+ latitude=body.latitude,
+ longitude=body.longitude,
+ timezone=body.timezone,
+ min_capacity=body.min_capacity,
+ max_capacity=body.max_capacity,
+ open_time=body.open_time,
+ close_time=body.close_time,
+ spans_next_day=body.spans_next_day,
+ allowed_booking_types=[bt.value for bt in body.allowed_booking_types],
+ booking_mode=body.booking_mode.value,
+ min_booking_duration_minutes=body.min_booking_duration_minutes,
+ max_booking_duration_minutes=body.max_booking_duration_minutes,
+ slot_interval_minutes=body.slot_interval_minutes,
+ pre_buffer_minutes=body.pre_buffer_minutes,
+ post_buffer_minutes=body.post_buffer_minutes,
+ pricing_mode=body.pricing_mode.value,
+ starting_price_paise=body.starting_price_paise,
+ hourly_rate_paise=body.hourly_rate_paise,
+ platform_commission_pct=DEFAULT_PLATFORM_COMMISSION_PCT,
+ advance_pct=body.advance_pct,
+ balance_due_days_before_event=body.balance_due_days_before_event,
+ owner_action_window_hours=body.owner_action_window_hours,
+ overdue_advance_refund_pct=body.overdue_advance_refund_pct,
+ min_price_pct=body.min_price_pct,
+ max_price_pct=body.max_price_pct,
+ status=VenueStatus.draft,
+ last_completed_step=body.last_completed_step,
+ is_active=True,
+ )
+
+ db.add(venue)
+ db.flush()
+
+ if body.cancellation_policy:
+ policy = VenueCancellationPolicy(
+ id=uuid.uuid4(),
+ venue_id=venue.id,
+ tier_1_hours=body.cancellation_policy.tier_1_hours,
+ tier_1_refund_pct=body.cancellation_policy.tier_1_refund_pct,
+ tier_2_hours=body.cancellation_policy.tier_2_hours,
+ tier_2_refund_pct=body.cancellation_policy.tier_2_refund_pct,
+ tier_3_hours=body.cancellation_policy.tier_3_hours,
+ tier_3_refund_pct=body.cancellation_policy.tier_3_refund_pct,
+ no_show_refund_pct=body.cancellation_policy.no_show_refund_pct,
+ platform_fee_refundable=body.cancellation_policy.platform_fee_refundable,
+ notes=body.cancellation_policy.notes,
+ )
+ db.add(policy)
+
+ if body.amenity_ids:
+ valid_amenities = (
+ db.query(Amenity)
+ .filter(
+ Amenity.id.in_(body.amenity_ids),
+ Amenity.deleted_at.is_(None),
+ )
+ .all()
+ )
+ if len(valid_amenities) != len(set(body.amenity_ids)):
+ raise ConflictError(
+ "One or more amenity IDs provided do not exist in the platform."
+ )
+
+ new_links = [
+ VenueAmenity(venue_id=venue.id, amenity_id=am_id)
+ for am_id in set(body.amenity_ids)
+ ]
+ db.add_all(new_links)
+
+ db.commit()
+ db.refresh(venue)
+
+ from app.modules.search.indexer import enqueue_job
+
+ enqueue_job(db, venue.id, "create")
+
+ return venue
+
+
+def update_venue(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: UpdateVenueRequest,
+) -> Venue:
+
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ update_data = body.model_dump(exclude_unset=True)
+
+ if "category_id" in update_data and update_data["category_id"] is not None:
+ _get_category_or_400(db, update_data["category_id"])
+
+ for field, value in update_data.items():
+
+ if hasattr(value, "value"):
+ value = value.value
+
+ if isinstance(value, list) and value and hasattr(value[0], "value"):
+ value = [item.value for item in value]
+ setattr(venue, field, value)
+
+ if venue.pricing_mode == "flat":
+ if venue.starting_price_paise is None:
+ raise ConflictError(
+ "starting_price_paise is required when pricing_mode is 'flat'"
+ )
+ if venue.hourly_rate_paise is not None:
+ raise ConflictError(
+ "hourly_rate_paise must be null when pricing_mode is 'flat'"
+ )
+ elif venue.pricing_mode == "hourly":
+ if venue.hourly_rate_paise is None:
+ raise ConflictError(
+ "hourly_rate_paise is required when pricing_mode is 'hourly'"
+ )
+ if venue.starting_price_paise is not None:
+ raise ConflictError(
+ "starting_price_paise must be null when pricing_mode is 'hourly'"
+ )
+ elif venue.pricing_mode == "mixed":
+ if venue.starting_price_paise is None or venue.hourly_rate_paise is None:
+ raise ConflictError(
+ "Both starting_price_paise and hourly_rate_paise are required when pricing_mode is 'mixed'"
+ )
+
+ if venue.min_capacity is not None and venue.min_capacity > venue.max_capacity:
+ raise ConflictError("min_capacity cannot exceed max_capacity")
+ if venue.min_booking_duration_minutes > venue.max_booking_duration_minutes:
+ raise ConflictError(
+ "min_booking_duration_minutes cannot exceed max_booking_duration_minutes"
+ )
+ if venue.min_price_pct > venue.max_price_pct:
+ raise ConflictError("min_price_pct cannot exceed max_price_pct")
+
+ _recompute_display_price_range(venue)
+
+ db.commit()
+ db.refresh(venue)
+
+ from app.modules.search.indexer import enqueue_job
+
+ enqueue_job(db, venue.id, "update")
+
+ return venue
+
+
+def delete_venue(db: Session, venue_id: UUID, owner_id: UUID) -> None:
+
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ if venue.status not in (
+ VenueStatus.draft,
+ VenueStatus.rejected,
+ VenueStatus.suspended,
+ ):
+ raise ConflictError(
+ f"Venue cannot be deleted in status '{venue.status.value}'. "
+ "Only draft, rejected, or suspended venues can be deleted."
+ )
+
+ venue.deleted_at = datetime.now(timezone.utc)
+ db.commit()
+
+
+def submit_venue(db: Session, venue_id: UUID, owner_id: UUID) -> Venue:
+
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ allowed_from = (VenueStatus.draft, VenueStatus.rejected)
+ if venue.status not in allowed_from:
+ raise ConflictError(
+ f"Cannot submit venue in status '{venue.status.value}'. "
+ "Only draft or rejected venues can be submitted for review."
+ )
+
+ if len(venue.photos) < MIN_VENUE_PHOTOS:
+ raise ConflictError(
+ f"At least {MIN_VENUE_PHOTOS} photos are required before submitting "
+ f"for review. This venue currently has {len(venue.photos)}."
+ )
+
+ venue.status = VenueStatus.pending_approval
+
+ from app.modules.deep_research.service import sync_reservation_status_for_venue
+
+ sync_reservation_status_for_venue(db, venue_id, VenueStatus.pending_approval)
+
+ db.commit()
+ db.refresh(venue)
+ return venue
+
+
+def get_venue_availability(db: Session, venue_id: UUID) -> list[VenueAvailability]:
+ _get_active_venue_or_404(db, venue_id)
+ return (
+ db.query(VenueAvailability)
+ .filter(
+ VenueAvailability.venue_id == venue_id,
+ VenueAvailability.deleted_at.is_(None),
+ )
+ .order_by(VenueAvailability.day_of_week.asc())
+ .all()
+ )
+
+
+def bulk_update_availability(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ availabilities: list[VenueAvailabilityUpdate],
+) -> list[VenueAvailability]:
+
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ now = datetime.now(timezone.utc)
+ db.query(VenueAvailability).filter(
+ VenueAvailability.venue_id == venue_id, VenueAvailability.deleted_at.is_(None)
+ ).update({"deleted_at": now}, synchronize_session=False)
+
+ new_records = []
+ for item in availabilities:
+ new_record = VenueAvailability(
+ id=uuid.uuid4(),
+ venue_id=venue_id,
+ day_of_week=item.day_of_week,
+ is_available=item.is_available,
+ opens_at=item.opens_at,
+ closes_at=item.closes_at,
+ spans_next_day=item.spans_next_day,
+ )
+ db.add(new_record)
+ new_records.append(new_record)
+
+ db.commit()
+ for rec in new_records:
+ db.refresh(rec)
+ return sorted(new_records, key=lambda x: x.day_of_week)
+
+
+def get_venue_blocked_dates(db: Session, venue_id: UUID) -> list[VenueBlockedDate]:
+ _get_active_venue_or_404(db, venue_id)
+ now = datetime.now(timezone.utc)
+ return (
+ db.query(VenueBlockedDate)
+ .filter(
+ VenueBlockedDate.venue_id == venue_id,
+ VenueBlockedDate.deleted_at.is_(None),
+ VenueBlockedDate.ends_at > now,
+ )
+ .order_by(VenueBlockedDate.starts_at.asc())
+ .all()
+ )
+
+
+def create_blocked_date(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: CreateBlockedDateRequest,
+) -> VenueBlockedDate:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ new_block = VenueBlockedDate(
+ id=uuid.uuid4(),
+ venue_id=venue_id,
+ starts_at=body.starts_at,
+ ends_at=body.ends_at,
+ reason=body.reason,
+ blocked_by=owner_id,
+ )
+ db.add(new_block)
+ db.commit()
+ db.refresh(new_block)
+ return new_block
+
+
+def delete_blocked_date(
+ db: Session, venue_id: UUID, blocked_id: UUID, owner_id: UUID
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ blocked_date = (
+ db.query(VenueBlockedDate)
+ .filter(
+ VenueBlockedDate.id == blocked_id,
+ VenueBlockedDate.venue_id == venue_id,
+ VenueBlockedDate.deleted_at.is_(None),
+ )
+ .first()
+ )
+
+ if not blocked_date:
+ raise NotFoundError("Blocked date not found")
+
+ blocked_date.deleted_at = datetime.now(timezone.utc)
+ db.commit()
+
+
+# Pricing rules
+
+
+def _rule_exceeds_bounds(rule: VenuePricingRule, venue: Venue) -> bool:
+ if rule.adjustment_type != "multiplier" or rule.multiplier is None:
+ return False
+ rule_pct = Decimal(str(rule.multiplier)) * Decimal("100")
+ return rule_pct < Decimal(str(venue.min_price_pct)) or rule_pct > Decimal(
+ str(venue.max_price_pct)
+ )
+
+
+def _rule_to_response(rule: VenuePricingRule, venue: Venue) -> VenuePricingRuleResponse:
+ return VenuePricingRuleResponse.model_validate(
+ rule, from_attributes=True
+ ).model_copy(update={"exceeds_bounds": _rule_exceeds_bounds(rule, venue)})
+
+
+def _recompute_display_price_range(venue: Venue) -> None:
+ """Recompute the venue's listing display range from its active multiplier rules.
+ Called transactionally on every pricing-rule / bounds write (PRD §6.6): no worker,
+ no cache invalidation -- just a cheap read-time-equivalent computation stored for
+ index-only listing queries.
+ """
+ base = (
+ venue.starting_price_paise
+ if venue.pricing_mode in ("flat", "mixed")
+ else venue.hourly_rate_paise
+ )
+
+ active_multiplier_rules = [
+ r
+ for r in venue.pricing_rules
+ if r.is_active
+ and r.adjustment_type == "multiplier"
+ and r.multiplier is not None
+ ]
+
+ if base is None or not active_multiplier_rules:
+ venue.display_price_min_paise = None
+ venue.display_price_max_paise = None
+ return
+
+ multipliers = [Decimal(str(r.multiplier)) for r in active_multiplier_rules]
+ low_multiplier = min(Decimal("1.0"), min(multipliers))
+ high_multiplier = max(Decimal("1.0"), max(multipliers))
+
+ min_pct = Decimal(str(venue.min_price_pct))
+ max_pct = Decimal(str(venue.max_price_pct))
+
+ display_min, _ = pricing_engine.clamp_price(
+ _banker_round(Decimal(str(base)) * low_multiplier), base, min_pct, max_pct
+ )
+ display_max, _ = pricing_engine.clamp_price(
+ _banker_round(Decimal(str(base)) * high_multiplier), base, min_pct, max_pct
+ )
+ venue.display_price_min_paise = display_min
+ venue.display_price_max_paise = display_max
+
+
+def list_pricing_rules(
+ db: Session, venue_id: UUID, owner_id: UUID
+) -> list[VenuePricingRuleResponse]:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ rules = (
+ db.query(VenuePricingRule)
+ .filter(
+ VenuePricingRule.venue_id == venue_id, VenuePricingRule.deleted_at.is_(None)
+ )
+ .order_by(VenuePricingRule.priority.desc(), VenuePricingRule.created_at.desc())
+ .all()
+ )
+ return [_rule_to_response(r, venue) for r in rules]
+
+
+def _get_pricing_rule_or_404(
+ db: Session, venue_id: UUID, rule_id: UUID
+) -> VenuePricingRule:
+ rule = (
+ db.query(VenuePricingRule)
+ .filter(
+ VenuePricingRule.id == rule_id,
+ VenuePricingRule.venue_id == venue_id,
+ VenuePricingRule.deleted_at.is_(None),
+ )
+ .first()
+ )
+ if not rule:
+ raise NotFoundError("Pricing rule not found")
+ return rule
+
+
+def create_pricing_rule(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: CreatePricingRuleRequest,
+) -> VenuePricingRuleResponse:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ if body.is_active:
+ active_count = (
+ db.query(VenuePricingRule)
+ .filter(
+ VenuePricingRule.venue_id == venue_id,
+ VenuePricingRule.deleted_at.is_(None),
+ VenuePricingRule.is_active.is_(True),
+ )
+ .count()
+ )
+ if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE:
+ raise ConflictError(
+ f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules"
+ )
+
+ rule = VenuePricingRule(
+ id=uuid.uuid4(),
+ venue_id=venue_id,
+ name=body.name,
+ days_of_week=body.days_of_week,
+ start_date=body.start_date,
+ end_date=body.end_date,
+ start_time=body.start_time,
+ end_time=body.end_time,
+ adjustment_type=body.adjustment_type.value,
+ multiplier=body.multiplier,
+ amount_paise=body.amount_paise,
+ applies_to=body.applies_to.value,
+ priority=body.priority,
+ source="owner",
+ is_active=body.is_active,
+ )
+ db.add(rule)
+ db.flush()
+ db.refresh(venue)
+
+ _recompute_display_price_range(venue)
+ db.commit()
+ db.refresh(rule)
+ return _rule_to_response(rule, venue)
+
+
+def update_pricing_rule(
+ db: Session,
+ venue_id: UUID,
+ rule_id: UUID,
+ owner_id: UUID,
+ body: UpdatePricingRuleRequest,
+) -> VenuePricingRuleResponse:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+ rule = _get_pricing_rule_or_404(db, venue_id, rule_id)
+
+ if body.is_active and not rule.is_active:
+ active_count = (
+ db.query(VenuePricingRule)
+ .filter(
+ VenuePricingRule.venue_id == venue_id,
+ VenuePricingRule.deleted_at.is_(None),
+ VenuePricingRule.is_active.is_(True),
+ )
+ .count()
+ )
+ if active_count >= MAX_ACTIVE_PRICING_RULES_PER_VENUE:
+ raise ConflictError(
+ f"Venue already has the maximum of {MAX_ACTIVE_PRICING_RULES_PER_VENUE} active pricing rules"
+ )
+
+ update_data = body.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ if hasattr(value, "value"):
+ value = value.value
+ setattr(rule, field, value)
+
+ if rule.adjustment_type == "multiplier":
+ if rule.multiplier is None:
+ raise ConflictError(
+ "multiplier is required when adjustment_type is 'multiplier'"
+ )
+ if rule.amount_paise is not None:
+ raise ConflictError(
+ "amount_paise must be null when adjustment_type is 'multiplier'"
+ )
+ else:
+ if rule.amount_paise is None:
+ raise ConflictError(
+ "amount_paise is required when adjustment_type is 'fixed_delta' or 'override'"
+ )
+ if rule.multiplier is not None:
+ raise ConflictError(
+ "multiplier must be null when adjustment_type is not 'multiplier'"
+ )
+ if rule.adjustment_type == "override" and rule.amount_paise < 0:
+ raise ConflictError(
+ "amount_paise must be >= 0 when adjustment_type is 'override'"
+ )
+
+ if (
+ rule.start_date is not None
+ and rule.end_date is not None
+ and rule.start_date > rule.end_date
+ ):
+ raise ConflictError("start_date cannot be after end_date")
+
+ db.flush()
+ db.refresh(venue)
+ _recompute_display_price_range(venue)
+ db.commit()
+ db.refresh(rule)
+ return _rule_to_response(rule, venue)
+
+
+def delete_pricing_rule(
+ db: Session, venue_id: UUID, rule_id: UUID, owner_id: UUID
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+ rule = _get_pricing_rule_or_404(db, venue_id, rule_id)
+
+ rule.deleted_at = datetime.now(timezone.utc)
+ db.flush()
+ db.refresh(venue)
+ _recompute_display_price_range(venue)
+ db.commit()
+
+
+def get_venue_cancellation_policy(
+ db: Session, venue_id: UUID
+) -> VenueCancellationPolicy:
+ _get_active_venue_or_404(db, venue_id)
+ policy = (
+ db.query(VenueCancellationPolicy)
+ .filter(VenueCancellationPolicy.venue_id == venue_id)
+ .first()
+ )
+
+ if not policy:
+ raise NotFoundError("Cancellation policy not found for this venue")
+
+ return policy
+
+
+def put_venue_cancellation_policy(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: UpdateCancellationPolicyRequest,
+) -> VenueCancellationPolicy:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ policy = (
+ db.query(VenueCancellationPolicy)
+ .filter(VenueCancellationPolicy.venue_id == venue_id)
+ .first()
+ )
+
+ if not policy:
+ policy = VenueCancellationPolicy(
+ id=uuid.uuid4(),
+ venue_id=venue_id,
+ )
+ db.add(policy)
+
+ policy.tier_1_hours = body.tier_1_hours
+ policy.tier_1_refund_pct = body.tier_1_refund_pct
+ policy.tier_2_hours = body.tier_2_hours
+ policy.tier_2_refund_pct = body.tier_2_refund_pct
+ policy.tier_3_hours = body.tier_3_hours
+ policy.tier_3_refund_pct = body.tier_3_refund_pct
+ policy.no_show_refund_pct = body.no_show_refund_pct
+ policy.platform_fee_refundable = body.platform_fee_refundable
+ policy.notes = body.notes
+
+ db.commit()
+ db.refresh(policy)
+ return policy
+
+
+def update_venue_amenities(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: UpdateVenueAmenitiesRequest,
+) -> list[Amenity]:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ if body.amenity_ids:
+ valid_amenities = (
+ db.query(Amenity)
+ .filter(
+ Amenity.id.in_(body.amenity_ids),
+ Amenity.deleted_at.is_(None),
+ )
+ .all()
+ )
+ if len(valid_amenities) != len(set(body.amenity_ids)):
+ raise ConflictError(
+ "One or more amenity IDs provided do not exist in the platform."
+ )
+
+ db.query(VenueAmenity).filter(VenueAmenity.venue_id == venue_id).delete(
+ synchronize_session=False
+ )
+
+ new_links = []
+ for am_id in set(body.amenity_ids):
+ new_links.append(VenueAmenity(venue_id=venue_id, amenity_id=am_id))
+
+ if new_links:
+ db.add_all(new_links)
+
+ db.commit()
+ db.refresh(venue)
+ return venue.amenities
+
+
+def add_venue_photo(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ file_bytes: bytes,
+) -> VenuePhoto:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ image_url = upload_image_to_cloudinary(file_bytes, folder=f"venues/{venue_id}")
+
+ existing_photos = (
+ db.query(VenuePhoto)
+ .filter(VenuePhoto.venue_id == venue_id, VenuePhoto.deleted_at.is_(None))
+ .all()
+ )
+
+ sort_order = len(existing_photos)
+ is_cover = True if sort_order == 0 else False
+
+ photo = VenuePhoto(
+ id=uuid.uuid4(),
+ venue_id=venue_id,
+ image_url=image_url,
+ sort_order=sort_order,
+ is_cover=is_cover,
+ )
+ db.add(photo)
+ db.commit()
+ db.refresh(photo)
+ return photo
+
+
+def bulk_update_venue_photos(
+ db: Session,
+ venue_id: UUID,
+ owner_id: UUID,
+ body: BulkUpdateVenuePhotosRequest,
+) -> list[VenuePhoto]:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ cover_count = sum(1 for p in body.photos if p.is_cover)
+ if cover_count != 1:
+ raise ConflictError("Exactly one photo must be marked as the cover photo.")
+
+ photo_ids_in_request = {p.photo_id for p in body.photos}
+
+ existing_photos = (
+ db.query(VenuePhoto)
+ .filter(VenuePhoto.venue_id == venue_id, VenuePhoto.deleted_at.is_(None))
+ .all()
+ )
+
+ existing_photo_map = {p.id: p for p in existing_photos}
+
+ if len(photo_ids_in_request) != len(existing_photos):
+ raise ConflictError(
+ "The request must include all active photos for this venue."
+ )
+
+ for p_id in photo_ids_in_request:
+ if p_id not in existing_photo_map:
+ raise NotFoundError(f"Photo with ID {p_id} not found in this venue")
+
+ for photo in existing_photos:
+ photo.is_cover = False
+ db.flush()
+
+ for item in body.photos:
+ photo = existing_photo_map[item.photo_id]
+ photo.sort_order = item.sort_order
+ photo.is_cover = item.is_cover
+
+ db.commit()
+
+ return sorted(existing_photos, key=lambda p: p.sort_order)
+
+
+def delete_venue_photo(
+ db: Session, venue_id: UUID, photo_id: UUID, owner_id: UUID
+) -> None:
+ venue = _get_venue_or_404(db, venue_id)
+ _assert_owner(venue, owner_id)
+
+ photo = (
+ db.query(VenuePhoto)
+ .filter(
+ VenuePhoto.id == photo_id,
+ VenuePhoto.venue_id == venue_id,
+ VenuePhoto.deleted_at.is_(None),
+ )
+ .first()
+ )
+
+ if not photo:
+ raise NotFoundError("Photo not found")
+
+ photo.deleted_at = datetime.now(timezone.utc)
+
+ if photo.is_cover:
+ photo.is_cover = False
+ next_photo = (
+ db.query(VenuePhoto)
+ .filter(
+ VenuePhoto.venue_id == venue_id,
+ VenuePhoto.id != photo_id,
+ VenuePhoto.deleted_at.is_(None),
+ )
+ .order_by(VenuePhoto.sort_order.asc())
+ .first()
+ )
+
+ if next_photo:
+ next_photo.is_cover = True
+ db.add(next_photo)
+
+ db.commit()
+
+
+def get_pricing_quote(
+ db: Session,
+ venue_id: UUID,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: BookingType,
+) -> PricingQuote:
+
+ preview = get_pricing_preview(
+ db=db,
+ venue_id=venue_id,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_type=booking_type,
+ )
+
+ return PricingQuote(
+ quoted_price_paise=preview.quoted_price_paise,
+ platform_commission_pct=preview.platform_commission_pct,
+ platform_fee_paise=preview.platform_fee_paise,
+ owner_payout_paise=preview.owner_payout_paise,
+ advance_pct=preview.advance_pct,
+ advance_due_paise=preview.advance_due_paise,
+ balance_due_paise=preview.balance_due_paise,
+ pricing_mode=preview.pricing_mode,
+ breakdown=preview.breakdown,
+ clamped=preview.clamped,
+ )
+
+
+def get_pricing_quote_for_slot(
+ db: Session,
+ venue_id: UUID,
+ starts_at: datetime,
+ ends_at: datetime,
+ booking_type: str,
+) -> PricingQuote:
+ """
+ Get pricing quote for a booking slot.
+ For use by availability and booking modules.
+ """
+
+ return get_pricing_quote(
+ db=db,
+ venue_id=venue_id,
+ starts_at=starts_at,
+ ends_at=ends_at,
+ booking_type=BookingType(booking_type),
+ )
+
+
+def get_venue_stats_this_month(db: Session, venue_id: UUID, owner_id: UUID) -> dict:
+ venue = _get_venue_or_404(db, venue_id)
+ if venue.owner_id != owner_id:
+ raise ForbiddenError("Not your venue")
+
+ now = datetime.now(timezone.utc)
+ start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
+
+ from app.modules.booking.models import BookingSlot
+
+ active_bookings = (
+ db.query(func.count(Booking.id))
+ .join(Booking.slot)
+ .filter(
+ Booking.venue_id == venue_id,
+ BookingSlot.ends_at >= now,
+ Booking.status == BookingStatus.confirmed,
+ )
+ .scalar()
+ or 0
+ )
+
+ ledger_rows = (
+ db.query(
+ LedgerEntry.entry_type,
+ LedgerEntry.direction,
+ func.sum(LedgerEntry.amount_paise).label("total"),
+ )
+ .filter(
+ LedgerEntry.venue_id == venue_id,
+ LedgerEntry.created_at >= start_of_month,
+ )
+ .group_by(LedgerEntry.entry_type, LedgerEntry.direction)
+ .all()
+ )
+
+ gross_volume = 0
+ platform_fees = 0
+ refunds_issued = 0
+
+ for entry_type, direction, total in ledger_rows:
+ val = int(total or 0)
+ if entry_type == "charge" and direction == "credit":
+ gross_volume += val
+ elif entry_type == "platform_fee" and direction == "debit":
+ platform_fees += val
+ elif entry_type == "refund" and direction == "debit":
+ refunds_issued += val
+
+ net_revenue = gross_volume - platform_fees - refunds_issued
+
+ return {"active_bookings": active_bookings, "revenue_this_month_paise": net_revenue}
diff --git a/apps/api/app/shared/models.py b/apps/api/app/shared/models.py
new file mode 100644
index 000000000..b4965f996
--- /dev/null
+++ b/apps/api/app/shared/models.py
@@ -0,0 +1,12 @@
+from datetime import datetime
+from sqlalchemy import DateTime, func
+from sqlalchemy.orm import mapped_column, Mapped
+
+
+class TimestampMixin:
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=func.now(), nullable=False
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False
+ )
diff --git a/apps/api/app/shared/pagination.py b/apps/api/app/shared/pagination.py
new file mode 100644
index 000000000..681413202
--- /dev/null
+++ b/apps/api/app/shared/pagination.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel
+from typing import Generic, TypeVar, List
+
+T = TypeVar("T")
+
+
+class PaginationParams(BaseModel):
+ page: int = 1
+ page_size: int = 20
+
+
+class Page(BaseModel, Generic[T]):
+ items: List[T]
+ total: int
+ page: int
+ page_size: int
diff --git a/apps/api/app/shared/utils.py b/apps/api/app/shared/utils.py
new file mode 100644
index 000000000..cb94204fb
--- /dev/null
+++ b/apps/api/app/shared/utils.py
@@ -0,0 +1,30 @@
+import uuid
+from datetime import datetime
+from fastapi import HTTPException
+
+
+def generate_id() -> str:
+ return str(uuid.uuid4())
+
+
+# Helper to parse datetime query parameters that might have '+' decoded as space
+def parse_timezone_datetime(val: str, field_name: str) -> datetime:
+ processed_val = val
+ if " " in val:
+ parts = val.split(" ")
+ if len(parts) == 2 and (":" in parts[1] or parts[1].isdigit()):
+ processed_val = "+".join(parts)
+ try:
+ return datetime.fromisoformat(processed_val)
+ except ValueError as e:
+ raise HTTPException(
+ status_code=422,
+ detail=[
+ {
+ "type": "datetime_from_date_parsing",
+ "loc": ["query", field_name],
+ "msg": f"Input should be a valid datetime or date, unexpected extra characters at the end of the input: {str(e)}",
+ "input": val,
+ }
+ ],
+ )
diff --git a/apps/api/app/tests/test_availability_service.py b/apps/api/app/tests/test_availability_service.py
new file mode 100644
index 000000000..315115c4c
--- /dev/null
+++ b/apps/api/app/tests/test_availability_service.py
@@ -0,0 +1,88 @@
+from datetime import date, datetime, time, timezone, timedelta
+from zoneinfo import ZoneInfo
+
+from app.modules.availability.service import (
+ resolve_operating_window,
+ expand_full_day_slot,
+ compute_effective_range,
+)
+from app.modules.booking.models import BookingType
+from app.modules.venue.models import Venue
+import app.modules.venue.service as venue_service
+
+
+def make_venue(tz="Asia/Kolkata"):
+ v = Venue()
+ v.timezone = tz
+ v.open_time = time(9, 0)
+ v.close_time = time(18, 0)
+ v.spans_next_day = False
+ v.pricing_mode = "hourly"
+ v.hourly_rate_paise = 10000
+ v.platform_commission_pct = 10.00
+ v.advance_pct = 30.00
+ return v
+
+
+def test_resolve_and_expand_basic():
+ v = make_venue()
+ op = resolve_operating_window(v, date(2026, 6, 7))
+ assert op.is_available is True
+ assert op.opens_at == time(9, 0)
+
+ starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 6, 7))
+ # Asia/Kolkata is UTC+5:30
+ assert starts_utc.tzinfo is not None
+ assert ends_utc.tzinfo is not None
+ assert (ends_utc - starts_utc).seconds == (18 - 9) * 3600
+
+
+def test_compute_effective_range():
+ tz = ZoneInfo("Asia/Kolkata")
+ starts = datetime(2026, 6, 7, 9, 0, tzinfo=tz)
+ ends = datetime(2026, 6, 7, 18, 0, tzinfo=tz)
+ es, ee = compute_effective_range(starts, ends, 15, 20)
+ assert es == starts - timedelta(minutes=15)
+ assert ee == ends + timedelta(minutes=20)
+
+
+def test_pricing_quote_hourly(monkeypatch):
+ v = make_venue()
+ tz = ZoneInfo(v.timezone)
+ starts = datetime(2026, 6, 7, 10, 0, tzinfo=tz).astimezone(timezone.utc)
+ ends = datetime(2026, 6, 7, 12, 30, tzinfo=tz).astimezone(timezone.utc)
+
+ # Pricing now resolves the venue from the DB; stub that lookup so we can
+ # exercise the (DB-free) pricing math on our in-memory venue.
+ monkeypatch.setattr(venue_service, "_get_active_venue_or_404", lambda db, venue_id: v)
+ q = venue_service.get_pricing_quote(
+ db=None, venue_id=None, starts_at=starts, ends_at=ends,
+ booking_type=BookingType.time_slot,
+ )
+ # duration 2.5 hours * 10000 paise = 25000 paise
+ assert q.quoted_price_paise == 25000
+ assert q.advance_due_paise == int(25000 * 0.30)
+
+
+def test_expand_full_day_handles_dst_forward():
+ # London DST starts 2026-03-29 (clocks forward 1 hour) -> day length 23 hours
+ v = make_venue(tz="Europe/London")
+ v.open_time = time(0, 0)
+ v.close_time = time(0, 0)
+ v.spans_next_day = True
+
+ starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 3, 29))
+ delta = ends_utc - starts_utc
+ assert delta.total_seconds() == 23 * 3600
+
+
+def test_expand_full_day_handles_dst_backward():
+ # London DST ends 2026-10-25 (clocks back 1 hour) -> day length 25 hours
+ v = make_venue(tz="Europe/London")
+ v.open_time = time(0, 0)
+ v.close_time = time(0, 0)
+ v.spans_next_day = True
+
+ starts_utc, ends_utc = expand_full_day_slot(v, date(2026, 10, 25))
+ delta = ends_utc - starts_utc
+ assert delta.total_seconds() == 25 * 3600
diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml
new file mode 100644
index 000000000..756de5e7d
--- /dev/null
+++ b/apps/api/pyproject.toml
@@ -0,0 +1,53 @@
+[project]
+name = "venue404-api"
+version = "0.0.1"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.111.0",
+ "uvicorn[standard]>=0.30.0",
+ "sqlalchemy>=2.0.0",
+ "alembic>=1.13.0",
+ "psycopg2-binary>=2.9.0",
+ "pydantic-settings>=2.3.0",
+ "email-validator>=2.2.0",
+ "passlib[bcrypt]>=1.7.4",
+ "python-jose[cryptography]>=3.3.0",
+ "stripe>=9.0.0",
+ "apscheduler>=3.10.0",
+ "cloudinary>=1.40.0",
+ "pillow>=10.0.0",
+ "python-multipart>=0.0.9",
+ "pgvector>=0.3.0",
+ "numpy>=1.26.0",
+ "upstash-redis>=1.0.0",
+ "httpx>=0.27.0",
+ "rapidfuzz>=3.14.5",
+ "reportlab>=4.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.0.0",
+ "pytest-asyncio>=0.23.0",
+ "pytest-dotenv>=0.5.2",
+ "ruff>=0.5.0",
+]
+
+[tool.pytest.ini_options]
+env_files = [".env.test"]
+
+[tool.ruff]
+target-version = "py312"
+line-length = 100
+exclude = ["alembic"]
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP"]
+
+[build-system]
+requires = ["setuptools>=69", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["app*"]
+exclude = ["tests*", "alembic*"]
diff --git a/apps/api/scripts/reindex_all_venues.py b/apps/api/scripts/reindex_all_venues.py
new file mode 100644
index 000000000..5ce84515f
--- /dev/null
+++ b/apps/api/scripts/reindex_all_venues.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""
+Re-enqueue ALL venues for search indexing.
+"""
+
+import logging
+import sys
+
+from app.core.database import SessionLocal
+from app.modules.search.indexer import enqueue_job
+from app.modules.venue.models import Venue, VenueStatus
+from app.modules.booking.models import Booking
+from app.modules.profile.models import Profile
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def reindex_all_venues(batch_size: int = 200):
+ db = SessionLocal()
+ processed = 0
+ total = 0
+
+ try:
+ # Count total venues
+ total = db.query(Venue).filter(Venue.deleted_at.is_(None)).count()
+ logger.info(f"Found {total} venues to reindex.")
+
+ offset = 0
+
+ while True:
+ venues = (
+ db.query(Venue)
+ .filter(Venue.deleted_at.is_(None))
+ # .filter(Venue.status == VenueStatus.approved) # Uncomment if needed
+ # .filter(Venue.is_active == True)
+ .order_by(Venue.id)
+ .offset(offset)
+ .limit(batch_size)
+ .all()
+ )
+
+ if not venues:
+ break
+
+ for venue in venues:
+ try:
+ enqueue_job(db, venue.id, "reindex")
+ processed += 1
+ if processed % 50 == 0:
+ logger.info(f"Progress: {processed}/{total} venues enqueued")
+ except Exception as e:
+ logger.error(f"Failed to enqueue venue {venue.id}: {e}")
+
+ offset += batch_size
+ db.commit() # Commit periodically
+
+ logger.info(f"✅ Successfully enqueued {processed} out of {total} venues.")
+
+ except Exception as exc:
+ logger.exception("Reindexing failed")
+ sys.exit(1)
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ reindex_all_venues()
diff --git a/apps/api/scripts/run_job.py b/apps/api/scripts/run_job.py
new file mode 100644
index 000000000..3c7711dd3
--- /dev/null
+++ b/apps/api/scripts/run_job.py
@@ -0,0 +1,59 @@
+import logging
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from app.core.logging import setup_logging
+
+setup_logging()
+
+logger = logging.getLogger(__name__)
+
+import app.modules.venue.models # noqa: F401
+import app.modules.profile.models # noqa: F401
+import app.modules.booking.models # noqa: F401
+
+from app.jobs.runner import JOBS, run_job as _run_job
+
+
+def run_job(job_name: str) -> bool:
+ if job_name not in JOBS:
+ print(f"Unknown job: {job_name}")
+ return False
+
+ print(f"\n🚀 Running {job_name}")
+
+ try:
+ processed = _run_job(job_name)
+ print(f"✅ {job_name} completed. Processed: {processed}")
+ return True
+
+ except Exception:
+ logger.exception("%s failed", job_name)
+ print(f"❌ {job_name} failed")
+ return False
+
+
+def run_all():
+ print("Running ALL jobs...\n")
+
+ success = 0
+
+ for job_name in JOBS:
+ if run_job(job_name):
+ success += 1
+
+ print(f"\nCompleted {success}/{len(JOBS)} jobs.")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "all":
+ run_all()
+ elif len(sys.argv) > 1:
+ run_job(sys.argv[1])
+ else:
+ print("Usage:")
+ print(" python run_job.py ")
+ print(" python run_job.py all")
+ print("\nAvailable jobs:", list(JOBS.keys()))
diff --git a/apps/api/scripts/text_indexer.py b/apps/api/scripts/text_indexer.py
new file mode 100644
index 000000000..d6da03025
--- /dev/null
+++ b/apps/api/scripts/text_indexer.py
@@ -0,0 +1,30 @@
+from pprint import pprint
+
+from sqlalchemy.orm import joinedload
+
+from app.core.database import SessionLocal
+from app.modules.booking.models import Booking
+from app.modules.venue.models import Venue
+from app.modules.profile.models import Profile
+from app.modules.search.indexer import _build_search_document
+
+db = SessionLocal()
+
+try:
+ venue = (
+ db.query(Venue)
+ .options(
+ joinedload(Venue.category),
+ joinedload(Venue.amenities),
+ )
+ .first()
+ )
+
+ if venue is None:
+ raise RuntimeError("No venue found")
+
+ document = _build_search_document(venue)
+ pprint(document)
+
+finally:
+ db.close()
diff --git a/apps/api/scripts/trigger_confirm_payment.py b/apps/api/scripts/trigger_confirm_payment.py
new file mode 100644
index 000000000..6c3b5f8e0
--- /dev/null
+++ b/apps/api/scripts/trigger_confirm_payment.py
@@ -0,0 +1,44 @@
+"""Manual trigger for confirm_payment - Docker friendly"""
+
+import sys
+from pathlib import Path
+
+# Add correct path for Docker
+sys.path.insert(0, "/app")
+
+from app.core.database import SessionLocal
+from app.modules.payment import service
+from app.modules.booking.models import Booking
+
+
+def main():
+ payment_intent_id = "pi_3TkRDgGhjSGtBU6K1jQuJXAE" # ← Change if needed
+
+ print(f"🔄 [Docker] Triggering confirm_payment for: {payment_intent_id}")
+
+ db = SessionLocal()
+ try:
+ service.confirm_payment(db, payment_intent_id)
+ db.commit()
+ print("✅ confirm_payment executed successfully!")
+
+ # Check result
+ booking = (
+ db.query(Booking)
+ .filter_by(id="bddb9e73-8931-483c-8fe4-00662144d4f6")
+ .first()
+ )
+ if booking:
+ print(
+ f"📊 Final Status → Booking: {booking.status} | Payment: {booking.payment_status}"
+ )
+
+ except Exception as e:
+ db.rollback()
+ print(f"❌ Error: {e}")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py
new file mode 100644
index 000000000..0ccd1bcf9
--- /dev/null
+++ b/apps/api/tests/conftest.py
@@ -0,0 +1,142 @@
+import time
+from datetime import date, timedelta
+from datetime import time as dt_time
+from uuid import UUID, uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from jose import jwt
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+
+# Hard stop if the DATABASE_URL looks like a real/production database.
+# .env.test should always point to a local throwaway DB.
+_db_url = settings.database_url
+assert "supabase.co" not in _db_url, (
+ "Tests must not run against Supabase. Set DATABASE_URL in apps/api/.env.test."
+)
+assert any(h in _db_url for h in ("localhost", "127.0.0.1", "0.0.0.0")), (
+ "Tests must use a local database. Set DATABASE_URL in apps/api/.env.test."
+)
+from app.core.database import SessionLocal
+from app.main import app
+from app.modules.profile.models import Profile, ProfileStatus, UserRole, UserRoleAssignment
+from app.modules.venue.models import Venue, VenueCategory, VenueStatus
+
+
+# ── Session-scoped: seed one category for the whole run ──────────────────────
+
+@pytest.fixture(scope="session")
+def category_id() -> UUID:
+ db = SessionLocal()
+ try:
+ db.execute(text("TRUNCATE venue_categories CASCADE"))
+ db.commit()
+ cat = VenueCategory(slug="test-hall", label="Test Hall", is_active=True, sort_order=0)
+ db.add(cat)
+ db.commit()
+ db.refresh(cat)
+ return cat.id
+ finally:
+ db.close()
+
+
+# ── Function-scoped: fresh DB state per test ─────────────────────────────────
+
+@pytest.fixture
+def db() -> Session:
+ session = SessionLocal()
+ yield session
+ session.rollback()
+ session.execute(text("TRUNCATE profiles CASCADE"))
+ session.commit()
+ session.close()
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+# ── Helpers (plain functions, not fixtures) ───────────────────────────────────
+
+def make_token(user_id: UUID, email: str) -> str:
+ now = int(time.time())
+ return jwt.encode(
+ {
+ "sub": str(user_id),
+ "email": email,
+ "aud": "authenticated",
+ "iat": now,
+ "exp": now + 3600,
+ },
+ settings.supabase_jwt_secret,
+ algorithm="HS256",
+ )
+
+
+def seed_user(db: Session, role: str) -> tuple[UUID, str]:
+ """Insert a profile + role row. Returns (user_id, bearer_token)."""
+ user_id = uuid4()
+ email = f"{role}-{user_id.hex[:6]}@test.com"
+ db.add(Profile(id=user_id, email=email, status=ProfileStatus.active))
+ db.add(UserRoleAssignment(user_id=user_id, role=UserRole(role)))
+ db.commit()
+ return user_id, make_token(user_id, email)
+
+
+def create_booking(client, token: str, venue_id: UUID, booking_date: date | None = None):
+ """POST /api/bookings/ and return the raw response."""
+ return client.post(
+ "/api/bookings/",
+ json={
+ "venue_id": str(venue_id),
+ "venue_name": "Test Venue",
+ "venue_cover_image": None,
+ "booking_type": "full_day",
+ "booking_date": (booking_date or date.today() + timedelta(days=30)).isoformat(),
+ "guest_count": 10,
+ "event_type": "corporate",
+ "user_notes": "test",
+ },
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+
+def seed_approved_venue(db: Session, owner_id: UUID, category_id: UUID) -> UUID:
+ """Insert a fully approved, active venue ready to accept bookings."""
+ venue_id = uuid4()
+ db.add(Venue(
+ id=venue_id,
+ owner_id=owner_id,
+ category_id=category_id,
+ name="Test Venue",
+ slug=f"test-venue-{venue_id.hex[:6]}",
+ address_line1="123 Test St",
+ city="Mumbai",
+ state="Maharashtra",
+ country="India",
+ timezone="Asia/Kolkata",
+ max_capacity=100,
+ open_time=dt_time(9, 0),
+ close_time=dt_time(21, 0),
+ allowed_booking_types=["full_day"],
+ min_booking_duration_minutes=60,
+ max_booking_duration_minutes=1440,
+ slot_interval_minutes=30,
+ pre_buffer_minutes=0,
+ post_buffer_minutes=0,
+ pricing_mode="flat",
+ starting_price_paise=1_000_000,
+ platform_commission_pct=10,
+ advance_pct=30,
+ balance_due_days_before_event=7,
+ owner_action_window_hours=48,
+ overdue_advance_refund_pct=0,
+ status=VenueStatus.approved,
+ is_active=True,
+ ))
+ db.commit()
+ return venue_id
diff --git a/apps/api/tests/test_admin_immutability.py b/apps/api/tests/test_admin_immutability.py
new file mode 100644
index 000000000..cb8298359
--- /dev/null
+++ b/apps/api/tests/test_admin_immutability.py
@@ -0,0 +1,118 @@
+"""
+Admin action immutability tests.
+
+Business rule (CLAUDE.md):
+ Admin actions are append-only. Never update. Never delete.
+
+Tests verify:
+ 1. The DB FK has ON DELETE RESTRICT — deleting an admin's profile is blocked
+ 2. AdminAction rows have no cascade delete from the ORM side
+ 3. No DELETE or PUT routes exist for admin_actions
+"""
+import pytest
+from sqlalchemy.exc import IntegrityError
+from uuid import uuid4
+
+from app.modules.admin.models import AdminAction
+from app.modules.profile.models import Profile
+from tests.conftest import seed_user
+
+
+# ── DB-level constraint ───────────────────────────────────────────────────────
+
+def test_cannot_delete_profile_with_admin_actions(db):
+ admin_id, _ = seed_user(db, "super_admin")
+
+ db.add(AdminAction(
+ id=uuid4(),
+ admin_id=admin_id,
+ action_type="venue_approved",
+ target_type="venue",
+ target_id=uuid4(),
+ reason="test",
+ ))
+ db.commit()
+
+ with pytest.raises(IntegrityError):
+ db.query(Profile).filter(Profile.id == admin_id).delete()
+ db.commit()
+
+ db.rollback()
+
+
+def test_admin_action_row_persists_after_failed_delete(db):
+ admin_id, _ = seed_user(db, "super_admin")
+ action_id = uuid4()
+
+ db.add(AdminAction(
+ id=action_id,
+ admin_id=admin_id,
+ action_type="venue_suspended",
+ target_type="venue",
+ target_id=uuid4(),
+ ))
+ db.commit()
+
+ try:
+ db.query(Profile).filter(Profile.id == admin_id).delete()
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+
+ row = db.query(AdminAction).filter(AdminAction.id == action_id).first()
+ assert row is not None
+
+
+# ── ORM-level: no cascade delete ─────────────────────────────────────────────
+
+def test_admin_action_model_has_no_cascade():
+ """The AdminAction model must not define a cascade that enables deletes."""
+ mapper = AdminAction.__mapper__
+ for rel in mapper.relationships:
+ assert "delete" not in (rel.cascade or ""), (
+ f"Relationship '{rel.key}' on AdminAction must not cascade deletes"
+ )
+
+
+# ── Route-level: no mutating routes for audit log ────────────────────────────
+
+def test_no_delete_route_for_admin_actions(client, db):
+ admin_id, admin_token = seed_user(db, "super_admin")
+ action_id = uuid4()
+
+ db.add(AdminAction(
+ id=action_id,
+ admin_id=admin_id,
+ action_type="venue_approved",
+ target_type="venue",
+ target_id=uuid4(),
+ ))
+ db.commit()
+
+ resp = client.delete(
+ f"/api/admin/actions/{action_id}",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ # 404 (route doesn't exist) or 405 (method not allowed) — either is correct
+ assert resp.status_code in (404, 405)
+
+
+def test_no_update_route_for_admin_actions(client, db):
+ admin_id, admin_token = seed_user(db, "super_admin")
+ action_id = uuid4()
+
+ db.add(AdminAction(
+ id=action_id,
+ admin_id=admin_id,
+ action_type="venue_approved",
+ target_type="venue",
+ target_id=uuid4(),
+ ))
+ db.commit()
+
+ resp = client.patch(
+ f"/api/admin/actions/{action_id}",
+ json={"reason": "overwritten"},
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ assert resp.status_code in (404, 405)
diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py
new file mode 100644
index 000000000..c56e797d0
--- /dev/null
+++ b/apps/api/tests/test_auth.py
@@ -0,0 +1,31 @@
+from tests.conftest import seed_user
+
+
+def test_missing_auth_header_returns_422(client):
+ # FastAPI rejects missing required headers before reaching auth logic
+ resp = client.get("/api/bookings/")
+ assert resp.status_code == 422
+
+
+def test_malformed_auth_header_returns_401(client):
+ # Header present but not in "Bearer " format
+ resp = client.get("/api/bookings/", headers={"Authorization": "notbearer"})
+ assert resp.status_code == 401
+
+
+def test_invalid_jwt_returns_401(client):
+ resp = client.get("/api/bookings/", headers={"Authorization": "Bearer bad.jwt.value"})
+ assert resp.status_code == 401
+
+
+def test_customer_on_owner_route_returns_403(client, db):
+ _, token = seed_user(db, "customer")
+ resp = client.get("/api/bookings/owner", headers={"Authorization": f"Bearer {token}"})
+ assert resp.status_code == 403
+
+
+def test_customer_can_access_auth_required_route(client, db):
+ _, token = seed_user(db, "customer")
+ resp = client.get("/api/bookings/", headers={"Authorization": f"Bearer {token}"})
+ # 200 with empty list — not 401/403
+ assert resp.status_code == 200
diff --git a/apps/api/tests/test_booking.py b/apps/api/tests/test_booking.py
new file mode 100644
index 000000000..cbf3d2ab4
--- /dev/null
+++ b/apps/api/tests/test_booking.py
@@ -0,0 +1,153 @@
+import pytest
+from unittest.mock import MagicMock
+from datetime import datetime, timedelta, date, timezone
+from uuid import uuid4
+from fastapi import HTTPException
+
+from app.modules.booking.models import Booking, BookingStatus, PaymentStatus, BookingSlot
+from app.modules.booking.state_machine import can_transition
+from app.modules.booking.cancellation import _compute_refund
+from app.modules.booking.service import (
+ owner_accept_booking,
+ owner_extend_deadline,
+)
+from app.modules.booking.schemas import ExtendDeadlineIn
+from app.modules.venue.models import VenueCancellationPolicy
+
+
+def test_status_transitions():
+ # Test valid transitions
+ assert can_transition(BookingStatus.requested, BookingStatus.owner_accepted) is True
+ assert can_transition(BookingStatus.requested, BookingStatus.owner_rejected) is True
+ assert can_transition(BookingStatus.owner_accepted, BookingStatus.confirmed) is True
+ assert can_transition(BookingStatus.confirmed, BookingStatus.completed) is True
+
+ # Test invalid transitions
+ assert can_transition(BookingStatus.completed, BookingStatus.requested) is False
+ assert can_transition(BookingStatus.user_cancelled, BookingStatus.confirmed) is False
+ assert can_transition(BookingStatus.owner_rejected, BookingStatus.owner_accepted) is False
+
+
+def test_refund_computation_no_policy():
+ booking = Booking(
+ amount_paid_paise=100000, # INR 1000
+ platform_fee_paise=10000, # INR 100
+ slot=BookingSlot(starts_at=datetime.now(timezone.utc) + timedelta(days=2))
+ )
+ # Without policy, refund should default to 0.0% (and match no_show or None tier)
+ result = _compute_refund(booking, None)
+ assert result.refund_amount_paise == 0
+ assert result.penalty_amount_paise == 100000
+ assert result.refund_pct_applied == 0.0
+
+
+def test_refund_computation_policy_fee_refundable():
+ policy = VenueCancellationPolicy(
+ tier_1_hours=48,
+ tier_1_refund_pct=100.0,
+ tier_2_hours=24,
+ tier_2_refund_pct=50.0,
+ tier_3_hours=12,
+ tier_3_refund_pct=25.0,
+ no_show_refund_pct=10.0,
+ platform_fee_refundable=True
+ )
+
+ starts_at = datetime.now(timezone.utc) + timedelta(days=3)
+ booking = Booking(
+ amount_paid_paise=100000, # INR 1000
+ platform_fee_paise=10000, # INR 100
+ slot=BookingSlot(starts_at=starts_at)
+ )
+
+ # Case 1: > 48 hours notice (Tier 1 -> 100% refund)
+ result = _compute_refund(booking, policy, cancelled_at=datetime.now(timezone.utc))
+ assert result.refund_amount_paise == 100000
+ assert result.refund_pct_applied == 100.0
+ assert result.tier_matched == "tier_1"
+
+ # Case 2: 30 hours notice (Tier 2 -> 50% refund of total 1000 = 500)
+ result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=30))
+ assert result.refund_amount_paise == 50000
+ assert result.refund_pct_applied == 50.0
+ assert result.tier_matched == "tier_2"
+
+ # Case 3: 5 hours notice (No show -> 10% refund of total 1000 = 100)
+ result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=5))
+ assert result.refund_amount_paise == 10000
+ assert result.refund_pct_applied == 10.0
+ assert result.tier_matched == "no_show"
+
+
+def test_refund_computation_policy_fee_non_refundable():
+ policy = VenueCancellationPolicy(
+ tier_1_hours=48,
+ tier_1_refund_pct=100.0,
+ tier_2_hours=24,
+ tier_2_refund_pct=50.0,
+ no_show_refund_pct=0.0,
+ platform_fee_refundable=False
+ )
+
+ starts_at = datetime.now(timezone.utc) + timedelta(days=3)
+ booking = Booking(
+ amount_paid_paise=100000, # INR 1000
+ platform_fee_paise=10000, # INR 100
+ slot=BookingSlot(starts_at=starts_at)
+ )
+
+ # Case 1: > 48 hours notice (Tier 1 -> 100% refund of owner share (900) = 900)
+ result = _compute_refund(booking, policy, cancelled_at=datetime.now(timezone.utc))
+ assert result.refund_amount_paise == 90000
+ assert result.refund_pct_applied == 100.0
+ assert result.tier_matched == "tier_1"
+
+ # Case 2: 30 hours notice (Tier 2 -> 50% refund of owner share (900) = 450)
+ result = _compute_refund(booking, policy, cancelled_at=starts_at - timedelta(hours=30))
+ assert result.refund_amount_paise == 45000
+ assert result.refund_pct_applied == 50.0
+ assert result.tier_matched == "tier_2"
+
+
+def test_owner_accept_booking_idempotency(monkeypatch):
+ import app.modules.booking.service as booking_service
+
+ db = MagicMock()
+ booking = MagicMock()
+ booking.status = BookingStatus.owner_accepted
+ booking.venue.owner_id = uuid4()
+
+ db.query().filter().with_for_update().first.return_value = booking
+
+ # _booking_out serializes a real Booking to a pydantic model; stub it so the
+ # test focuses on the idempotency control flow, not response serialization.
+ sentinel = object()
+ monkeypatch.setattr(booking_service, "_booking_out", lambda b: sentinel)
+
+ # Calling accept on an already accepted booking should return current state
+ # and NOT recreate intents or flush.
+ result = owner_accept_booking(db, uuid4(), booking.venue.owner_id)
+ assert result is sentinel
+ db.flush.assert_not_called()
+
+
+def test_owner_extend_deadline_validation():
+ db = MagicMock()
+ booking = MagicMock()
+ booking.status = BookingStatus.confirmed
+ booking.payment_status = PaymentStatus.advance_paid
+ booking.balance_overdue_at = datetime.now(timezone.utc)
+ booking.deadline_extension_count = 0
+ booking.venue.owner_id = uuid4()
+
+ # Slot starts in the past relative to execution
+ booking.slot.starts_at = datetime.now(timezone.utc) - timedelta(hours=2)
+
+ db.query().filter().with_for_update().first.return_value = booking
+
+ # Extension on already started event should fail
+ body = ExtendDeadlineIn(new_due_date=date.today() + timedelta(days=2))
+ with pytest.raises(HTTPException) as exc_info:
+ owner_extend_deadline(db, uuid4(), booking.venue.owner_id, body)
+ assert exc_info.value.status_code == 400
+ assert "Cannot extend deadline for a past or ongoing event" in exc_info.value.detail
diff --git a/apps/api/tests/test_booking_integration.py b/apps/api/tests/test_booking_integration.py
new file mode 100644
index 000000000..72a4bca9c
--- /dev/null
+++ b/apps/api/tests/test_booking_integration.py
@@ -0,0 +1,132 @@
+from datetime import date, timedelta
+
+from app.modules.booking.models import Booking, BookingStatus
+from tests.conftest import seed_approved_venue, seed_user
+
+
+def _booking_date() -> str:
+ # Use a date 30 days out so it always passes the "must be future" check
+ return (date.today() + timedelta(days=30)).isoformat()
+
+
+def _create_booking(client, customer_token: str, venue_id: str, venue_name: str) -> dict:
+ resp = client.post(
+ "/api/bookings/",
+ json={
+ "venue_id": venue_id,
+ "venue_name": venue_name,
+ "venue_cover_image": None,
+ "booking_type": "full_day",
+ "booking_date": _booking_date(),
+ "guest_count": 10,
+ "event_type": "corporate",
+ "user_notes": "integration test booking",
+ },
+ headers={"Authorization": f"Bearer {customer_token}"},
+ )
+ return resp
+
+
+# ── Auth guards ───────────────────────────────────────────────────────────────
+
+def test_create_booking_unauthenticated_returns_422(client):
+ resp = client.post("/api/bookings/", json={})
+ assert resp.status_code == 422
+
+
+def test_accept_booking_as_customer_returns_403(client, db, category_id):
+ owner_id, owner_token = seed_user(db, "venue_owner")
+ customer_id, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+ assert create_resp.status_code == 201
+ booking_id = create_resp.json()["id"]
+
+ # Customer trying to accept their own booking should be forbidden
+ resp = client.post(
+ f"/api/bookings/{booking_id}/accept",
+ headers={"Authorization": f"Bearer {customer_token}"},
+ )
+ assert resp.status_code == 403
+
+
+# ── Status transitions ────────────────────────────────────────────────────────
+
+def test_create_booking_status_is_requested(client, db, category_id):
+ owner_id, _ = seed_user(db, "venue_owner")
+ _, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+
+ assert resp.status_code == 201
+ assert resp.json()["status"] == "requested"
+
+
+def test_create_booking_row_in_db(client, db, category_id):
+ owner_id, _ = seed_user(db, "venue_owner")
+ _, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+ assert resp.status_code == 201
+
+ booking_id = resp.json()["id"]
+ row = db.query(Booking).filter(Booking.id == booking_id).first()
+ assert row is not None
+ assert row.status == BookingStatus.requested
+
+
+def test_owner_accept_transitions_to_owner_accepted(client, db, category_id):
+ owner_id, owner_token = seed_user(db, "venue_owner")
+ _, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+ assert create_resp.status_code == 201
+ booking_id = create_resp.json()["id"]
+
+ accept_resp = client.post(
+ f"/api/bookings/{booking_id}/accept",
+ headers={"Authorization": f"Bearer {owner_token}"},
+ )
+
+ assert accept_resp.status_code == 200
+ assert accept_resp.json()["status"] == "owner_accepted"
+
+
+def test_owner_accept_sets_hold_expiry_in_db(client, db, category_id):
+ owner_id, owner_token = seed_user(db, "venue_owner")
+ _, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+ booking_id = create_resp.json()["id"]
+
+ client.post(
+ f"/api/bookings/{booking_id}/accept",
+ headers={"Authorization": f"Bearer {owner_token}"},
+ )
+
+ db.expire_all()
+ row = db.query(Booking).filter(Booking.id == booking_id).first()
+ assert row.status == BookingStatus.owner_accepted
+ assert row.hold_expires_at is not None
+
+
+def test_wrong_owner_cannot_accept_booking(client, db, category_id):
+ owner_id, _ = seed_user(db, "venue_owner")
+ other_owner_id, other_owner_token = seed_user(db, "venue_owner")
+ _, customer_token = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ create_resp = _create_booking(client, customer_token, str(venue_id), "Test Venue")
+ booking_id = create_resp.json()["id"]
+
+ # A different owner trying to accept someone else's venue booking
+ resp = client.post(
+ f"/api/bookings/{booking_id}/accept",
+ headers={"Authorization": f"Bearer {other_owner_token}"},
+ )
+ assert resp.status_code in (403, 404)
diff --git a/apps/api/tests/test_conflict_flow.py b/apps/api/tests/test_conflict_flow.py
new file mode 100644
index 000000000..a45f83185
--- /dev/null
+++ b/apps/api/tests/test_conflict_flow.py
@@ -0,0 +1,85 @@
+"""
+Conflict flow tests.
+
+Business rules under test (from CLAUDE.md):
+- Multiple users may request the same slot
+- Requesting a slot does NOT reserve it
+- Acceptance starts a 24-hour hold but does NOT block competing requests
+- Only one confirmed booking can exist per slot
+- After acceptance, NEW requests for the same slot are rejected (slot is blocking)
+"""
+from app.modules.booking.models import Booking, BookingStatus
+from app.modules.booking.state_machine import can_transition
+from tests.conftest import create_booking, seed_approved_venue, seed_user
+
+
+# ── State machine (unit) ──────────────────────────────────────────────────────
+
+def test_requested_can_transition_to_conflict_cancelled():
+ assert can_transition(BookingStatus.requested, BookingStatus.conflict_cancelled) is True
+
+
+def test_conflict_cancelled_is_terminal():
+ for target in BookingStatus:
+ assert can_transition(BookingStatus.conflict_cancelled, target) is False
+
+
+def test_hold_expired_is_terminal():
+ for target in BookingStatus:
+ assert can_transition(BookingStatus.hold_expired, target) is False
+
+
+# ── Multiple requests for same slot (integration) ─────────────────────────────
+
+def test_two_customers_can_request_same_slot(client, db, category_id):
+ owner_id, _ = seed_user(db, "venue_owner")
+ _, token1 = seed_user(db, "customer")
+ _, token2 = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ resp1 = create_booking(client, token1, venue_id)
+ resp2 = create_booking(client, token2, venue_id)
+
+ assert resp1.status_code == 201
+ assert resp2.status_code == 201
+ assert resp1.json()["id"] != resp2.json()["id"]
+
+
+def test_competing_request_stays_requested_after_acceptance(client, db, category_id):
+ owner_id, owner_token = seed_user(db, "venue_owner")
+ _, token1 = seed_user(db, "customer")
+ _, token2 = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ b1 = create_booking(client, token1, venue_id).json()
+ b2 = create_booking(client, token2, venue_id).json()
+
+ # Owner accepts booking 1
+ resp = client.post(
+ f"/api/bookings/{b1['id']}/accept",
+ headers={"Authorization": f"Bearer {owner_token}"},
+ )
+ assert resp.status_code == 200
+
+ # Booking 2 stays "requested" — conflict_cancelled only happens at payment confirmation
+ db.expire_all()
+ row = db.query(Booking).filter(Booking.id == b2["id"]).first()
+ assert row.status == BookingStatus.requested
+
+
+def test_new_request_blocked_after_acceptance(client, db, category_id):
+ owner_id, owner_token = seed_user(db, "venue_owner")
+ _, token1 = seed_user(db, "customer")
+ _, token3 = seed_user(db, "customer")
+ venue_id = seed_approved_venue(db, owner_id, category_id)
+
+ b1 = create_booking(client, token1, venue_id).json()
+
+ client.post(
+ f"/api/bookings/{b1['id']}/accept",
+ headers={"Authorization": f"Bearer {owner_token}"},
+ )
+
+ # A brand-new request for the same slot after acceptance should be blocked
+ resp = create_booking(client, token3, venue_id)
+ assert resp.status_code == 409
diff --git a/apps/api/tests/test_email.py b/apps/api/tests/test_email.py
new file mode 100644
index 000000000..b435efbd6
--- /dev/null
+++ b/apps/api/tests/test_email.py
@@ -0,0 +1,63 @@
+import smtplib
+
+from app.core import email as email_mod
+from app.core.config import settings
+
+
+def test_no_transport_configured_is_noop(monkeypatch):
+ monkeypatch.setattr(settings, "resend_api_key", "")
+ monkeypatch.setattr(settings, "smtp_host", "")
+ assert email_mod.send_email("a@b.com", "Subject", "
+ Applications are reviewed by our team. We'll email you once a decision is made.
+
+
+
+ }
+ right={}
+ />
+ )
+}
diff --git a/apps/owner-portal/src/pages/RegisterSuccess.tsx b/apps/owner-portal/src/pages/RegisterSuccess.tsx
new file mode 100644
index 000000000..13e1c43b9
--- /dev/null
+++ b/apps/owner-portal/src/pages/RegisterSuccess.tsx
@@ -0,0 +1,26 @@
+import { Link } from 'react-router-dom'
+
+export default function RegisterSuccess() {
+ return (
+
+
+
+
+
+
Account created!
+
+ Please check your email to confirm your account. Once confirmed, log in — your application
+ will be reviewed by our team before you can access the owner portal.
+
0 ? 'text-rose-700/80' : 'text-zinc-500 dark:text-zinc-400 dark:text-zinc-500'}`}>
+ {isForfeitCancelled && `Customer missed the balance payment deadline. Advance of ${fmt(advanceDue)} is forfeited. Your net share after ${commissionPct}% commission: ${fmt(ownerPayoutProjected)}.`}
+ {isUserCancelled && refundAmount > 0 && `${fmt(refundAmount)} was refunded to the customer based on your cancellation policy. You retain ${fmt(amountPaid - refundAmount)} minus platform commission.`}
+ {isUserCancelled && refundAmount === 0 && `No refund was issued to the customer based on your cancellation policy. You retain the full collected amount.`}
+ {isAdminCancelled && 'This booking was cancelled by a platform administrator.'}
+
+ Due date was {booking.balance_due_date && new Date(booking.balance_due_date).toLocaleDateString()}.
+ {isOverdue && ` Marked overdue at ${new Date(booking.balance_overdue_at!).toLocaleString()}.`}
+ {' '}Customer has used {booking.deadline_extension_count} of 2 allowed extension(s).
+
+ Block out specific dates and times when your venue will be unavailable for booking (e.g., for maintenance, private events, or holidays). Blocked dates will override your standard weekly schedule.
+
+
+
+
+
+
+
+
Upcoming Blocked Dates
+ {blockedDates.length === 0 ? (
+
+
+
+
+
No upcoming blocked dates.
+
Dates you block will appear here so you can easily unblock them later.
Take your time to add photos, configure pricing, and review your policies. When you are fully satisfied and ready to accept bookings, submit it for review to make it live.
+ Example: a ₹{((venue.starting_price_paise ?? venue.hourly_rate_paise ?? 0) / 100).toLocaleString('en-IN')} base price
+ always stays between {minPct}% and {maxPct}% of that value.
+
+ )}
+
+
+
+ {/* Add rule */}
+
+
+
+
+
+ Add Pricing Rule
+
+
Create a new dynamic pricing adjustment.
+
+
+
+
+
+
+
+ {/* Rules list */}
+
+
+
+
+
+ Your Rules
+
+
Manage your active pricing rules ({rules.length}).
+
+
+
+ {rules.length === 0 ? (
+
+
+
No pricing rules yet. Your base price applies to every booking.
+
+ ) : (
+
+ {rules.map(rule => (
+
+
+
+ {rule.name}
+ priority {rule.priority}
+ {rule.exceeds_bounds && (
+ will be capped
+ )}
+ {!rule.is_active && (inactive)}
+
+
{ruleSummary(rule)}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Live preview */}
+
+
+
+
+
+ Preview a price
+
+
See exactly how your rules apply to a specific date.