From 69e4277e7910e38b5408e9fcf2fcd299d22e3fdd Mon Sep 17 00:00:00 2001 From: Carlos Sanchez Gonzalez Date: Fri, 10 Jul 2026 19:50:03 -0600 Subject: [PATCH] Remove Supabase runtime dependency --- BUILD_LOG.md | 38 +++- PRE_LAUNCH_CHECKLIST.md | 26 +-- QUICKSTART.md | 25 +-- README.md | 51 ++--- server/.env.example | 20 +- server/alembic.ini | 3 +- server/alembic/env.py | 3 +- .../alembic/versions/0001_initial_schema.py | 2 +- server/core/config.py | 24 ++- server/database.py | 137 +++++++++++++ server/main.py | 14 +- server/repositories/attachments.py | 40 ++-- server/repositories/invitations.py | 65 +++---- server/repositories/organizations.py | 100 +++------- server/repositories/password_resets.py | 38 ++-- server/repositories/priority_rules.py | 52 ++--- server/repositories/technicians.py | 75 ++++---- server/repositories/users.py | 71 +++---- server/repositories/work_order_events.py | 46 ++--- server/repositories/work_orders.py | 116 +++++------ server/requirements.txt | 2 +- server/routers/work_orders.py | 4 +- server/schema.sql | 24 +-- server/services/attachment_storage_service.py | 64 +++++-- server/supabase_client.py | 34 ---- .../tests/test_attachment_storage_service.py | 56 +++--- server/tests/test_tenant_isolation.py | 180 +++++------------- 27 files changed, 622 insertions(+), 688 deletions(-) create mode 100644 server/database.py delete mode 100644 server/supabase_client.py diff --git a/BUILD_LOG.md b/BUILD_LOG.md index 181bfc9..d3fa815 100644 --- a/BUILD_LOG.md +++ b/BUILD_LOG.md @@ -24,8 +24,7 @@ docs also advertised those credentials. ### Remaining Follow-Up - Run a secret/history scan before making the repo public. -- If any Supabase project was already initialized with the previous schema, - manually remove or rotate the former shared demo users there. +- If any existing database was already initialized with the previous schema,`n manually remove or rotate the former shared demo users there. ## 2026-07-09 - Make Production Configuration Explicit @@ -41,8 +40,7 @@ hosted POC fail in confusing ways or accidentally point users at fake domains. in production builds when it is missing. - Backend settings now support `APP_ENV=production` and validate required production values at startup. -- Production backend startup now requires Supabase settings, JWT secret, CORS - origins, and Stripe/mock checkout callback URLs. +- Production backend startup now requires `DATABASE_URL`, object storage settings,`n JWT secret, CORS origins, and Stripe/mock checkout callback URLs. - Production CORS validation rejects localhost origins. - `.env.example`, README, Quick Start, and mobile config docs now describe the deployment variables explicitly. @@ -191,15 +189,39 @@ end-to-end. ### Changed -- Added configurable Supabase Storage settings for the work-order attachments bucket and max upload size. -- Added a backend upload service that validates image/PDF content type, size, and filename safety before uploading to Supabase Storage. +- Added configurable S3-compatible storage settings for the work-order attachments bucket and max upload size. +- Added a backend upload service that validates image/PDF content type, size, and filename safety before uploading to S3-compatible object storage. - Added `POST /work-orders/{id}/attachments/upload` while keeping the existing metadata endpoint compatible. -- Added backend tests for upload path generation, Supabase Storage calls, content-type rejection, size rejection, and extension mismatch rejection. +- Added backend tests for upload path generation, S3-compatible storage calls, content-type rejection, size rejection, and extension mismatch rejection. - Added `expo-image-picker` plus camera/photo-library permissions to the mobile app. - Updated the work order details screen to load attachments, take or choose a photo, upload it, and open existing attachments. - Updated README and the pre-launch checklist to mark RF-19 complete for the hosted POC path. ### Remaining Follow-Up -- Create the `work-order-attachments` Supabase Storage bucket in the production project before the hosted demo. +- Create the `work-order-attachments` bucket in the chosen object-storage provider before the hosted demo. - Smoke-test photo capture/upload on a real Android/iOS build after the next native rebuild. +## 2026-07-10 - Remove Supabase Runtime Dependency + +### Why + +Supabase was no longer the chosen foundation for the hosted POC. The backend +already owned auth, tenant scoping, migrations, and file upload endpoints, so a +portable managed-Postgres plus S3-compatible storage architecture is a better +fit for the public demo path. + +### Changed + +- Added `server/database.py` for direct SQLAlchemy/psycopg2 Postgres access via `DATABASE_URL`. +- Migrated runtime repositories from `supabase-py` query-builder calls to explicit Postgres SQL/helper calls. +- Replaced `supabase` with `boto3` in backend requirements. +- Reworked attachment uploads to use S3-compatible `put_object` storage and public base URLs. +- Removed the obsolete `server/supabase_client.py` runtime helper. +- Updated tenant-isolation tests to assert direct SQL/helper scoping by `organization_id`. +- Updated `.env.example`, README, and the pre-launch checklist for managed Postgres plus S3/R2-style storage. + +### Remaining Follow-Up + +- Choose the hosted providers (for example Neon/Render Postgres plus Cloudflare R2) and provision real credentials. +- Run `alembic upgrade head` against the production/demo Postgres database. +- Smoke-test attachment upload against the chosen object-storage bucket. diff --git a/PRE_LAUNCH_CHECKLIST.md b/PRE_LAUNCH_CHECKLIST.md index ab372dd..9b8e6d7 100644 --- a/PRE_LAUNCH_CHECKLIST.md +++ b/PRE_LAUNCH_CHECKLIST.md @@ -16,10 +16,7 @@ before real customer data goes in, **Nice-to-have** can trail behind launch. -hex 32`) — never reuse the one from local dev, never commit it. Store it in your hosting provider's secret manager, not in a committed `.env`. -- [ ] **Create a production Supabase project** (separate from any dev/test - project) and run `alembic upgrade head` (or paste `schema.sql`) - against it. Use the `service_role` key only on the backend server, - never ship it to the mobile app or a browser. +- [ ] **Create a production managed Postgres database** (separate from any dev/test database) and run `alembic upgrade head` against it. Keep `DATABASE_URL` only in the backend host secret manager. - [ ] **Put the backend behind HTTPS** (RNF-04 requires TLS, no exceptions). If you deploy to Render/Fly.io/Railway/Vercel this is usually automatic; if you're running the Docker image yourself, put @@ -33,7 +30,7 @@ before real customer data goes in, **Nice-to-have** can trail behind launch. requires SMTP settings and `APP_BASE_URL` at startup. - [ ] **Run a secret scan** over the whole repo history before making it public (`git log` + tools like `gitleaks` or `trufflehog`) — confirm - no real Supabase/Stripe/JWT keys were ever committed, only the + no real database/storage/Stripe/JWT keys were ever committed, only the `.env.example` placeholders. ## 🟠 Important — do before real customer data / real money touches this @@ -47,19 +44,9 @@ before real customer data goes in, **Nice-to-have** can trail behind launch. `/invitations/accept`). The backend now has configurable in-process fixed window limits for single-instance POC hosting. For multi-instance hosting, move these limits to Redis or the edge/reverse proxy. -- [ ] **Decide on the RLS/service-role gap.** The backend currently uses - the Supabase `service_role` key, which bypasses Row Level Security — - application-layer `organization_id` scoping is the real enforcement - today (see README "Multi-Tenancy Model"). Before this holds real - customer data, either (a) accept and document that app-layer - scoping is your primary control and add automated regression tests - that fail loudly if a repository function ever drops its - `organization_id` filter, or (b) do the work to issue - PostgREST-compatible JWTs with an `organization_id` claim and switch - reads to the `anon`/`authenticated` key so RLS is actually live in - production, not just verified manually in dev. +- [x] **Remove the Supabase service-role runtime dependency.** Repositories now use direct Postgres via `DATABASE_URL`, storage uses S3-compatible credentials, and tenant isolation is enforced through app-layer `organization_id` scoping covered by regression tests. - [x] **Wire up real object storage for attachments (RF-19).** The backend - now uploads files through Supabase Storage and records attachment + now uploads files through S3-compatible object storage and records attachment metadata; the mobile work order details screen can take or choose a photo and attach it to the job. - [x] **Move mobile token storage off AsyncStorage** (RF-04). Native app @@ -72,12 +59,11 @@ before real customer data goes in, **Nice-to-have** can trail behind launch. `LOG_FORMAT=json` logs, which nobody's watching in real time. - [ ] **Set up basic uptime monitoring** (UptimeRobot, Better Uptime, or your host's built-in health checks against `GET /health`). -- [ ] **Confirm Supabase automated backups are enabled** on the production +- [ ] **Confirm managed Postgres automated backups are enabled** on the production project (paid tiers include point-in-time recovery — check your plan). - [x] **Add a CI pipeline** (GitHub Actions) that runs backend pytest and client Jest checks on pushes/PRs. Backend coverage currently includes - `server/tests/` (58 tests); client coverage starts with shared validation - tests and should grow as mobile flows are hardened. + `server/tests/` (63 tests); client coverage starts with shared validation tests and should grow as mobile flows are hardened. - [ ] **Complete the Expo/React Native dependency upgrade.** Safe npm audit fixes reduced the client report from 39 to 29 findings with no criticals, but the remaining high/moderate findings are pinned inside Expo 50 / React diff --git a/QUICKSTART.md b/QUICKSTART.md index 673db12..fedd7c3 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -2,18 +2,13 @@ ## 5-Minute Demo Script -Since TechSync is now multi-tenant, the backend requires a real Supabase -project (there's no more "run with no DB, get mock data" mode — tenant -isolation needs a real database). Budget a couple extra minutes the first -time to create a free Supabase project and run the schema. +Since TechSync is multi-tenant, the backend requires a real Postgres database for runtime data. Budget a few minutes to create a managed Postgres database (Neon, Render, Railway, Fly, or local Postgres) and run the schema. -### Step 0: One-time Supabase setup +### Step 0: One-time database setup -1. Create a free project at https://supabase.com -2. Open the SQL Editor and paste/run the contents of `server/schema.sql` - (or run `alembic upgrade head` from `server/` with `DATABASE_URL` set to - your project's direct Postgres connection string) -3. Grab your Project URL and `service_role` key from Project Settings → API +1. Create a managed Postgres database. +2. Set `DATABASE_URL` to that database connection string. +3. Run `alembic upgrade head` from `server/`. ### Step 1: Start the Backend (1 minute) @@ -23,8 +18,7 @@ pip install -r requirements.txt cat > .env << EOF APP_ENV=development -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_KEY=your-service-role-key +DATABASE_URL=postgresql://user:password@host:5432/techsync JWT_SECRET_KEY=$(openssl rand -hex 32) CORS_ORIGINS=http://localhost:8081,http://localhost:19006,http://localhost:3000 STRIPE_SUCCESS_URL=http://localhost:3000/billing/success @@ -109,7 +103,7 @@ ship with shared demo credentials. ## Key Features to Showcase 1. **Multi-tenant isolation** — every table is scoped by `organization_id`, - backed by Postgres RLS (see README "Multi-Tenancy Model") + backed by application-layer tenant scoping plus the schema RLS backstop (see README "Multi-Tenancy Model") 2. **Self-service onboarding** — a brand-new org + admin account in one API call, no manual provisioning 3. **Rule-based auto-assignment** — new work orders are matched to a @@ -142,9 +136,8 @@ cd client && npm start -- --reset-cache - Physical Device: `http://YOUR_COMPUTER_IP:8000` **"503 Service requires database configuration":** -- `SUPABASE_URL`/`SUPABASE_KEY` are missing or wrong in `server/.env` — see - Step 0 above. There's no mock-data fallback anymore since tenant - isolation needs a real database. +- `DATABASE_URL` is missing or wrong in `server/.env` - see Step 0 above. + There's no mock-data fallback anymore since tenant isolation needs a real database. **Access token expired errors:** - The mobile app refreshes automatically. Via curl, call diff --git a/README.md b/README.md index c0d50cf..53fcf74 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A multi-tenant SaaS platform for field service companies to onboard their organization, ingest work orders from multiple sources, auto-assign them to the right technician, and track them to completion — with a React Native -mobile app for technicians and a FastAPI + Supabase backend. +mobile app for technicians and a FastAPI + managed Postgres backend. This repository implements the POC scope defined in `Techsync_SaaS_Requirements.md` (Functional/Non-Functional requirements @@ -27,8 +27,8 @@ and at the database layer via Postgres Row Level Security. **Backend** - FastAPI + Uvicorn -- Supabase (PostgreSQL), accessed via `supabase-py` (PostgREST) at runtime - and via a direct Postgres connection for Alembic migrations +- Managed PostgreSQL, accessed directly through SQLAlchemy/psycopg2 repositories +- S3-compatible object storage for work order attachments (Cloudflare R2, AWS S3, etc.) - Pydantic v2 for request/response validation - Alembic for versioned migrations - JWT (access + refresh) for auth, bcrypt for password hashing @@ -47,12 +47,12 @@ and at the database layer via Postgres Row Level Security. ├── main.py # app wiring: routers, CORS, exception handlers ├── core/ # config.py, security.py (JWT, hashing) ├── models/ # Pydantic request/response schemas - ├── repositories/ # Supabase data access, always org-scoped + ├── repositories/ # direct Postgres data access, always org-scoped ├── services/ # business logic (matching, ingestion, billing...) ├── routers/ # HTTP endpoints, one file per resource ├── dependencies.py # auth, tenant-scoping, role-check dependencies ├── alembic/ # versioned DB migrations (RNF-10) - ├── schema.sql # same schema, for pasting into Supabase SQL editor + ├── schema.sql # same schema, for managed Postgres setup ├── tests/ # pytest suite (auth, matching, tenant isolation...) ├── Dockerfile / .dockerignore └── requirements.txt @@ -72,22 +72,13 @@ Two enforcement layers: core repositories. `server/dependencies.py::get_current_organization` resolves the caller's org from their JWT and every router depends on it. 2. **Row Level Security (backstop)** — every tenant table has RLS enabled - with a policy scoping rows to `techsync_current_org_id()`, which reads an - `organization_id` claim off the PostgREST JWT. This was manually verified + with a policy scoping rows to `techsync_current_org_id()`, which reads an `organization_id` claim from the database session. This was manually verified against a local Postgres instance during development: with RLS on and no org claim set, queries return zero rows (fail-closed); with the claim set to org A, only org A's rows are visible, even though org B's rows exist in the same table. -**Caveat**: the backend currently talks to Supabase using the -`service_role` key (needed for cross-tenant admin operations like -onboarding), which bypasses RLS by design in Supabase. That makes the -application-layer scoping the actual enforcement path today. RLS is fully -defined and tested so that the moment any code path uses the `anon` key -with a user-scoped JWT (e.g. a future "mobile app talks to Supabase -directly" path), it's already protected — see the comment block at the top -of `server/schema.sql` for how to mint a PostgREST-compatible JWT with an -`organization_id` claim. +**Direct runtime model**: the FastAPI backend now talks directly to Postgres with `DATABASE_URL`; there is no Supabase service-role runtime dependency. Application-layer `organization_id` scoping is the primary tenant boundary and is covered by repository regression tests. RLS policies remain in the schema as a database backstop for deployments that intentionally set a per-request `organization_id` claim in the database session. ## Getting Started @@ -95,7 +86,7 @@ of `server/schema.sql` for how to mint a PostgREST-compatible JWT with an - Node.js 16+ - Python 3.11+ -- A Supabase project (or local Postgres for schema/migration testing) +- A managed Postgres database (Neon, Render, Railway, Fly, local Postgres, etc.) - Android Studio or Xcode for mobile development ### Backend Setup @@ -109,11 +100,16 @@ cp .env.example .env Edit `.env`: ``` APP_ENV=development -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_KEY=your-service-role-key -DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres +DATABASE_URL=postgresql://user:password@host:5432/techsync JWT_SECRET_KEY=$(openssl rand -hex 32) CORS_ORIGINS=http://localhost:8081,http://localhost:19006,http://localhost:3000 +STORAGE_BUCKET=work-order-attachments +STORAGE_REGION=auto +STORAGE_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com +STORAGE_ACCESS_KEY_ID=your-storage-access-key +STORAGE_SECRET_ACCESS_KEY=your-storage-secret-key +STORAGE_PUBLIC_BASE_URL=https://files.yourdomain.com/work-order-attachments +ATTACHMENT_MAX_BYTES=10485760 STRIPE_SECRET_KEY= STRIPE_PRICE_ID= STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret @@ -122,12 +118,9 @@ STRIPE_CANCEL_URL=http://localhost:3000/billing/cancel APP_BASE_URL=http://localhost:19006 EMAIL_DELIVERY_METHOD=log EMAIL_FROM=TechSync -SUPABASE_ATTACHMENTS_BUCKET=work-order-attachments -ATTACHMENT_MAX_BYTES=10485760 ``` -Apply the schema — either paste `schema.sql` into the Supabase SQL editor, -or run the equivalent migration (RNF-10): +Apply the schema with Alembic (recommended), or run `schema.sql` in your managed Postgres SQL console (RNF-10): ```bash alembic upgrade head ``` @@ -145,7 +138,7 @@ cd server pip install -r requirements-dev.txt pytest -p no:cacheprovider ``` -62 backend tests covering JWT/password logic, the matching engine, CSV ingestion +63 backend tests covering JWT/password logic, the matching engine, CSV ingestion validation, work order status transitions, plan-limit enforcement, tenant-isolation of the repository layer, public endpoint rate limiting, and Stripe webhook handling, and attachment upload validation. These run without a live @@ -270,7 +263,7 @@ Implemented for this POC pass (mapped to `Techsync_SaaS_Requirements.md`): mode, signed Stripe webhook handling for checkout completion/payment failure/subscription cancellation, or a mock URL when Stripe is not configured), RF-29 (technician-count plan limit, enforced server-side). -- **NFRs**: RNF-05 (RLS, verified manually — see Multi-Tenancy Model above), +- **NFRs**: RNF-05 (application-layer tenant isolation with repository regression tests; RLS policies remain as an optional DB backstop), RNF-06 (bcrypt), RNF-09 (modular backend structure), RNF-10 (Alembic), RNF-11 (Dockerfile/compose), RNF-12 (structured JSON logging, toggle via `LOG_FORMAT=json`), RNF-13 (tenant deletion endpoint). @@ -287,11 +280,7 @@ Implemented for this POC pass (mapped to `Techsync_SaaS_Requirements.md`): applied without forcing framework majors. The remaining npm findings are in Expo 50 / React Native 0.73 transitive tooling and require a planned Expo/RN upgrade before treating the client as public-showcase clean. -- Docker image build wasn't network-testable in the sandbox this was built - in (registry pull blocked); the Alembic migration itself *was* run - end-to-end against a real local Postgres instance and confirmed to create - the correct schema, indexes, and RLS policies, and RLS behavior was - hand-verified with a non-superuser role. +- Docker image build was not network-testable in the sandbox this was built in (registry pull blocked); verify the image build in your deployment environment before relying on it. ## License diff --git a/server/.env.example b/server/.env.example index ce3311b..359029e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,16 +2,17 @@ # Use "production" in hosted environments so required settings are validated at startup. APP_ENV=development -# Supabase Configuration (PostgREST API used by the running app) -# Get these values from your Supabase project settings -> API -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_KEY=your-service-role-key-here -SUPABASE_ATTACHMENTS_BUCKET=work-order-attachments -ATTACHMENT_MAX_BYTES=10485760 +# Database configuration (managed Postgres: Neon, Render, Railway, Fly, etc.) +DATABASE_URL=postgresql://user:password@host:5432/techsync -# Direct Postgres connection string, used only by Alembic migrations -# (Supabase project settings -> Database -> Connection string -> URI) -DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres +# S3-compatible attachment storage (Cloudflare R2, AWS S3, etc.) +STORAGE_BUCKET=work-order-attachments +STORAGE_REGION=auto +STORAGE_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com +STORAGE_ACCESS_KEY_ID=your-storage-access-key +STORAGE_SECRET_ACCESS_KEY=your-storage-secret-key +STORAGE_PUBLIC_BASE_URL=https://files.yourdomain.com/work-order-attachments +ATTACHMENT_MAX_BYTES=10485760 # JWT Authentication (RF-01) # Generate a secure random key: openssl rand -hex 32 @@ -34,6 +35,7 @@ RATE_LIMIT_ONBOARD_MAX=3 RATE_LIMIT_ONBOARD_WINDOW_SECONDS=300 RATE_LIMIT_INVITATION_ACCEPT_MAX=10 RATE_LIMIT_INVITATION_ACCEPT_WINDOW_SECONDS=300 + # Multi-tenancy / billing defaults (RF-06, RF-27, RF-29) TRIAL_LENGTH_DAYS=14 DEFAULT_TECHNICIAN_LIMIT=3 diff --git a/server/alembic.ini b/server/alembic.ini index fc923c8..8ab0e2f 100644 --- a/server/alembic.ini +++ b/server/alembic.ini @@ -5,8 +5,7 @@ version_path_separator = os # sqlalchemy.url is intentionally omitted here -- alembic/env.py reads it from # the DATABASE_URL environment variable so the same migration can target -# local Postgres, CI, or a Supabase project's direct Postgres connection -# string (Supabase project settings -> Database -> Connection string). +# local Postgres, CI, or any managed Postgres connection string. [loggers] keys = root,sqlalchemy,alembic diff --git a/server/alembic/env.py b/server/alembic/env.py index 2656cfe..4f6cd19 100644 --- a/server/alembic/env.py +++ b/server/alembic/env.py @@ -1,8 +1,7 @@ """ Alembic environment (RNF-10: versioned, reproducible migrations). -Reads the target Postgres connection string from DATABASE_URL, e.g. the -Supabase project's direct Postgres connection string +Reads the target Postgres connection string from DATABASE_URL for local or managed Postgres (Project Settings -> Database -> Connection string -> URI, "Direct connection" or "Session pooler" -- not the PostgREST/REST URL used elsewhere in the app). """ diff --git a/server/alembic/versions/0001_initial_schema.py b/server/alembic/versions/0001_initial_schema.py index f2955d8..dd23647 100644 --- a/server/alembic/versions/0001_initial_schema.py +++ b/server/alembic/versions/0001_initial_schema.py @@ -18,7 +18,7 @@ depends_on: Union[str, Sequence[str], None] = None # Single source of truth for the schema lives in server/schema.sql so it can -# also be pasted directly into the Supabase SQL editor for quick setup. +# be applied to any managed Postgres database. SCHEMA_SQL_PATH = Path(__file__).resolve().parents[2] / "schema.sql" diff --git a/server/core/config.py b/server/core/config.py index ecd2859..c361092 100644 --- a/server/core/config.py +++ b/server/core/config.py @@ -38,9 +38,13 @@ class Settings: APP_ENV: str = APP_ENV IS_PRODUCTION: bool = IS_PRODUCTION - SUPABASE_URL: str | None = os.getenv("SUPABASE_URL") - SUPABASE_KEY: str | None = os.getenv("SUPABASE_KEY") - SUPABASE_ATTACHMENTS_BUCKET: str = os.getenv("SUPABASE_ATTACHMENTS_BUCKET", "work-order-attachments") + DATABASE_URL: str | None = os.getenv("DATABASE_URL") + STORAGE_BUCKET: str | None = os.getenv("STORAGE_BUCKET") + STORAGE_REGION: str = os.getenv("STORAGE_REGION", "auto") + STORAGE_ENDPOINT_URL: str | None = os.getenv("STORAGE_ENDPOINT_URL") + STORAGE_ACCESS_KEY_ID: str | None = os.getenv("STORAGE_ACCESS_KEY_ID") + STORAGE_SECRET_ACCESS_KEY: str | None = os.getenv("STORAGE_SECRET_ACCESS_KEY") + STORAGE_PUBLIC_BASE_URL: str | None = os.getenv("STORAGE_PUBLIC_BASE_URL") ATTACHMENT_MAX_BYTES: int = int(os.getenv("ATTACHMENT_MAX_BYTES", str(10 * 1024 * 1024))) JWT_SECRET_KEY: str | None = os.getenv("JWT_SECRET_KEY") @@ -102,10 +106,8 @@ def _validate_settings(value: Settings) -> None: return missing = [] - if not value.SUPABASE_URL: - missing.append("SUPABASE_URL") - if not value.SUPABASE_KEY: - missing.append("SUPABASE_KEY") + if not value.DATABASE_URL: + missing.append("DATABASE_URL") if not value.JWT_SECRET_KEY: missing.append("JWT_SECRET_KEY") if not value.CORS_ORIGINS: @@ -120,6 +122,14 @@ def _validate_settings(value: Settings) -> None: missing.append("APP_BASE_URL") if not value.EMAIL_FROM: missing.append("EMAIL_FROM") + if not value.STORAGE_BUCKET: + missing.append("STORAGE_BUCKET") + if not value.STORAGE_ACCESS_KEY_ID: + missing.append("STORAGE_ACCESS_KEY_ID") + if not value.STORAGE_SECRET_ACCESS_KEY: + missing.append("STORAGE_SECRET_ACCESS_KEY") + if not value.STORAGE_PUBLIC_BASE_URL: + missing.append("STORAGE_PUBLIC_BASE_URL") if value.EMAIL_DELIVERY_METHOD != "smtp": missing.append("EMAIL_DELIVERY_METHOD=smtp") diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..45837d3 --- /dev/null +++ b/server/database.py @@ -0,0 +1,137 @@ +"""Direct Postgres helpers for TechSync repositories.""" + +import json +import re +from functools import lru_cache +from typing import Any + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine, RowMapping + +from core.config import settings + +_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +JSONB_COLUMNS = {"settings"} + + +class DatabaseNotConfigured(Exception): + """Raised when DATABASE_URL is missing for runtime repository access.""" + + +@lru_cache(maxsize=1) +def get_engine() -> Engine: + if not settings.DATABASE_URL: + raise DatabaseNotConfigured("DATABASE_URL is not configured.") + return create_engine(settings.DATABASE_URL, pool_pre_ping=True, future=True) + + +def row_to_dict(row: RowMapping | None) -> dict | None: + if row is None: + return None + return dict(row) + + +def fetch_one(sql: str, params: dict[str, Any] | None = None) -> dict | None: + with get_engine().connect() as conn: + row = conn.execute(text(sql), _coerce_params(params or {})).mappings().first() + return row_to_dict(row) + + +def fetch_all(sql: str, params: dict[str, Any] | None = None) -> list[dict]: + with get_engine().connect() as conn: + rows = conn.execute(text(sql), _coerce_params(params or {})).mappings().all() + return [dict(row) for row in rows] + + +def fetch_scalar(sql: str, params: dict[str, Any] | None = None) -> Any: + with get_engine().connect() as conn: + return conn.execute(text(sql), _coerce_params(params or {})).scalar() + + +def execute(sql: str, params: dict[str, Any] | None = None) -> None: + with get_engine().begin() as conn: + conn.execute(text(sql), _coerce_params(params or {})) + + +def insert_row(table: str, payload: dict[str, Any]) -> dict: + _validate_identifier(table) + columns = list(payload.keys()) + for column in columns: + _validate_identifier(column) + + column_sql = ", ".join(columns) + value_sql = ", ".join(_value_expr(column) for column in columns) + sql = f"INSERT INTO {table} ({column_sql}) VALUES ({value_sql}) RETURNING *" + return fetch_one_in_transaction(sql, payload) + + +def update_row(table: str, patch: dict[str, Any], where: dict[str, Any]) -> dict | None: + _validate_identifier(table) + if not patch: + return select_one(table, where) + + params: dict[str, Any] = {} + set_parts = [] + for column, value in patch.items(): + _validate_identifier(column) + param_name = f"set_{column}" + params[param_name] = value + expression = f"CAST(:{param_name} AS jsonb)" if column in JSONB_COLUMNS else f":{param_name}" + set_parts.append(f"{column} = {expression}") + + where_sql = _where_clause(where, params) + sql = f"UPDATE {table} SET {', '.join(set_parts)} WHERE {where_sql} RETURNING *" + return fetch_one_in_transaction(sql, params) + + +def delete_rows(table: str, where: dict[str, Any]) -> list[dict]: + _validate_identifier(table) + params: dict[str, Any] = {} + where_sql = _where_clause(where, params) + sql = f"DELETE FROM {table} WHERE {where_sql} RETURNING *" + with get_engine().begin() as conn: + rows = conn.execute(text(sql), _coerce_params(params)).mappings().all() + return [dict(row) for row in rows] + + +def select_one(table: str, where: dict[str, Any]) -> dict | None: + _validate_identifier(table) + params: dict[str, Any] = {} + where_sql = _where_clause(where, params) + sql = f"SELECT * FROM {table} WHERE {where_sql} LIMIT 1" + return fetch_one(sql, params) + + +def fetch_one_in_transaction(sql: str, params: dict[str, Any]) -> dict: + with get_engine().begin() as conn: + row = conn.execute(text(sql), _coerce_params(params)).mappings().first() + return dict(row) if row else None + + +def _where_clause(where: dict[str, Any], params: dict[str, Any]) -> str: + parts = [] + for column, value in where.items(): + _validate_identifier(column) + param_name = f"where_{column}" + params[param_name] = value + parts.append(f"{column} = :{param_name}") + if not parts: + raise ValueError("Refusing to run a repository operation without a WHERE clause") + return " AND ".join(parts) + + +def _value_expr(column: str) -> str: + return f"CAST(:{column} AS jsonb)" if column in JSONB_COLUMNS else f":{column}" + + +def _coerce_params(params: dict[str, Any]) -> dict[str, Any]: + coerced = dict(params) + for key, value in list(coerced.items()): + if key.replace("set_", "", 1) in JSONB_COLUMNS or key in JSONB_COLUMNS: + coerced[key] = json.dumps(value or {}) + return coerced + + +def _validate_identifier(identifier: str) -> None: + if not _IDENTIFIER.match(identifier): + raise ValueError(f"Unsafe SQL identifier: {identifier}") diff --git a/server/main.py b/server/main.py index e71d27e..2c7914d 100644 --- a/server/main.py +++ b/server/main.py @@ -10,9 +10,10 @@ from fastapi.responses import JSONResponse from core.config import settings +from database import DatabaseNotConfigured from logger import logger from routers import auth, billing, dashboard, ingestion, invitations, organizations, technicians, users, work_orders -from supabase_client import SupabaseNotConfigured +from services.attachment_storage_service import StorageNotConfigured app = FastAPI( title="TechSync API", @@ -28,12 +29,15 @@ allow_headers=["*"], ) - -@app.exception_handler(SupabaseNotConfigured) -async def supabase_not_configured_handler(request: Request, exc: SupabaseNotConfigured): - logger.error("supabase.not_configured", extra={"event": "supabase_not_configured", "path": request.url.path}) +@app.exception_handler(DatabaseNotConfigured) +async def database_not_configured_handler(request: Request, exc: DatabaseNotConfigured): + logger.error("database.not_configured", extra={"event": "database_not_configured", "path": request.url.path}) return JSONResponse(status_code=503, content={"detail": "Service requires database configuration"}) +@app.exception_handler(StorageNotConfigured) +async def storage_not_configured_handler(request: Request, exc: StorageNotConfigured): + logger.error("storage.not_configured", extra={"event": "storage_not_configured", "path": request.url.path}) + return JSONResponse(status_code=503, content={"detail": "Service requires attachment storage configuration"}) @app.get("/health") def health_check(): diff --git a/server/repositories/attachments.py b/server/repositories/attachments.py index 147096e..6bb40f4 100644 --- a/server/repositories/attachments.py +++ b/server/repositories/attachments.py @@ -1,33 +1,27 @@ """Data access for work order attachments (RF-19).""" -from supabase_client import get_supabase_client +from database import fetch_all, insert_row def create(organization_id: int, work_order_id: int, uploaded_by: int, patch: dict) -> dict: - client = get_supabase_client() - response = ( - client.table("work_order_attachments") - .insert( - { - "organization_id": organization_id, - "work_order_id": work_order_id, - "uploaded_by": uploaded_by, - **patch, - } - ) - .execute() + return insert_row( + "work_order_attachments", + { + "organization_id": organization_id, + "work_order_id": work_order_id, + "uploaded_by": uploaded_by, + **patch, + }, ) - return response.data[0] def list_for_work_order(organization_id: int, work_order_id: int) -> list[dict]: - client = get_supabase_client() - response = ( - client.table("work_order_attachments") - .select("*") - .eq("organization_id", organization_id) - .eq("work_order_id", work_order_id) - .order("created_at", desc=True) - .execute() + return fetch_all( + """ + SELECT * + FROM work_order_attachments + WHERE organization_id = :organization_id AND work_order_id = :work_order_id + ORDER BY created_at DESC + """, + {"organization_id": organization_id, "work_order_id": work_order_id}, ) - return response.data or [] diff --git a/server/repositories/invitations.py b/server/repositories/invitations.py index 1c61ccb..ed81de6 100644 --- a/server/repositories/invitations.py +++ b/server/repositories/invitations.py @@ -4,56 +4,47 @@ from typing import Optional from core.config import settings -from supabase_client import get_supabase_client +from database import execute, fetch_all, fetch_one, insert_row def create_invitation(organization_id: int, email: str, role: str, token_hash: str, invited_by: int) -> dict: - client = get_supabase_client() expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.INVITE_EXPIRE_HOURS) - response = ( - client.table("invitations") - .insert( - { - "organization_id": organization_id, - "email": email, - "role": role, - "token_hash": token_hash, - "invited_by": invited_by, - "expires_at": expires_at.isoformat(), - } - ) - .execute() + return insert_row( + "invitations", + { + "organization_id": organization_id, + "email": email, + "role": role, + "token_hash": token_hash, + "invited_by": invited_by, + "expires_at": expires_at, + }, ) - return response.data[0] def get_valid_by_token_hash(token_hash: str) -> Optional[dict]: - client = get_supabase_client() - now = datetime.now(timezone.utc).isoformat() - response = ( - client.table("invitations") - .select("*") - .eq("token_hash", token_hash) - .is_("accepted_at", "null") - .gt("expires_at", now) - .execute() + return fetch_one( + """ + SELECT * + FROM invitations + WHERE token_hash = :token_hash + AND accepted_at IS NULL + AND expires_at > :now + LIMIT 1 + """, + {"token_hash": token_hash, "now": datetime.now(timezone.utc)}, ) - return response.data[0] if response.data else None def mark_accepted(invitation_id: int) -> None: - client = get_supabase_client() - now = datetime.now(timezone.utc).isoformat() - client.table("invitations").update({"accepted_at": now}).eq("id", invitation_id).execute() + execute( + "UPDATE invitations SET accepted_at = :accepted_at WHERE id = :invitation_id", + {"accepted_at": datetime.now(timezone.utc), "invitation_id": invitation_id}, + ) def list_by_org(organization_id: int) -> list[dict]: - client = get_supabase_client() - response = ( - client.table("invitations") - .select("*") - .eq("organization_id", organization_id) - .order("created_at", desc=True) - .execute() + return fetch_all( + "SELECT * FROM invitations WHERE organization_id = :organization_id ORDER BY created_at DESC", + {"organization_id": organization_id}, ) - return response.data or [] diff --git a/server/repositories/organizations.py b/server/repositories/organizations.py index 0b6a2a2..c49618a 100644 --- a/server/repositories/organizations.py +++ b/server/repositories/organizations.py @@ -5,7 +5,7 @@ from typing import Optional from core.config import settings -from supabase_client import get_supabase_client +from database import delete_rows, fetch_one, insert_row, select_one, update_row def slugify(name: str) -> str: @@ -20,119 +20,71 @@ def create_organization( industry: Optional[str], org_timezone: str, ) -> dict: - client = get_supabase_client() - base_slug = slugify(name) slug = base_slug suffix = 1 - while client.table("organizations").select("id").eq("slug", slug).execute().data: + while select_one("organizations", {"slug": slug}): suffix += 1 slug = f"{base_slug}-{suffix}" trial_ends_at = datetime.now(timezone.utc) + timedelta(days=settings.TRIAL_LENGTH_DAYS) - response = ( - client.table("organizations") - .insert( - { - "name": name, - "slug": slug, - "industry": industry, - "timezone": org_timezone, - "plan": "trial", - "subscription_status": "trialing", - "trial_ends_at": trial_ends_at.isoformat(), - "technician_limit": settings.DEFAULT_TECHNICIAN_LIMIT, - "api_key": secrets.token_urlsafe(24), - } - ) - .execute() + return insert_row( + "organizations", + { + "name": name, + "slug": slug, + "industry": industry, + "timezone": org_timezone, + "plan": "trial", + "subscription_status": "trialing", + "trial_ends_at": trial_ends_at, + "technician_limit": settings.DEFAULT_TECHNICIAN_LIMIT, + "api_key": secrets.token_urlsafe(24), + }, ) - return response.data[0] def get_by_api_key(api_key: str) -> Optional[dict]: - client = get_supabase_client() - response = client.table("organizations").select("*").eq("api_key", api_key).execute() - return response.data[0] if response.data else None + return select_one("organizations", {"api_key": api_key}) def regenerate_api_key(organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("organizations") - .update({"api_key": secrets.token_urlsafe(24)}) - .eq("id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("organizations", {"api_key": secrets.token_urlsafe(24)}, {"id": organization_id}) def get_by_id(organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = client.table("organizations").select("*").eq("id", organization_id).execute() - return response.data[0] if response.data else None + return select_one("organizations", {"id": organization_id}) def get_by_stripe_customer_id(stripe_customer_id: str) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("organizations") - .select("*") - .eq("stripe_customer_id", stripe_customer_id) - .execute() - ) - return response.data[0] if response.data else None + return select_one("organizations", {"stripe_customer_id": stripe_customer_id}) def update_settings(organization_id: int, settings_patch: dict) -> Optional[dict]: - client = get_supabase_client() current = get_by_id(organization_id) if not current: return None merged = {**(current.get("settings") or {}), **settings_patch} - response = ( - client.table("organizations") - .update({"settings": merged}) - .eq("id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("organizations", {"settings": merged}, {"id": organization_id}) def update_timezone(organization_id: int, org_timezone: str) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("organizations") - .update({"timezone": org_timezone}) - .eq("id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("organizations", {"timezone": org_timezone}, {"id": organization_id}) def update_billing(organization_id: int, patch: dict) -> Optional[dict]: - client = get_supabase_client() - response = client.table("organizations").update(patch).eq("id", organization_id).execute() - return response.data[0] if response.data else None + return update_row("organizations", patch, {"id": organization_id}) def soft_delete(organization_id: int) -> bool: """RNF-13: allow deletion of a tenant's data on request.""" - client = get_supabase_client() - now = datetime.now(timezone.utc).isoformat() - response = ( - client.table("organizations") - .update({"deleted_at": now}) - .eq("id", organization_id) - .execute() - ) - return bool(response.data) + updated = update_row("organizations", {"deleted_at": datetime.now(timezone.utc)}, {"id": organization_id}) + return bool(updated) def hard_delete(organization_id: int) -> bool: """Actually erase a tenant's rows (cascades via FK) -- used for POC test cleanup.""" - client = get_supabase_client() - response = client.table("organizations").delete().eq("id", organization_id).execute() - return bool(response.data) + rows = delete_rows("organizations", {"id": organization_id}) + return bool(rows) diff --git a/server/repositories/password_resets.py b/server/repositories/password_resets.py index d2ff516..88c6e01 100644 --- a/server/repositories/password_resets.py +++ b/server/repositories/password_resets.py @@ -4,35 +4,33 @@ from typing import Optional from core.config import settings -from supabase_client import get_supabase_client +from database import execute, fetch_one, insert_row def create_reset_token(user_id: int, token_hash: str) -> dict: - client = get_supabase_client() expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.RESET_TOKEN_EXPIRE_MINUTES) - response = ( - client.table("password_reset_tokens") - .insert({"user_id": user_id, "token_hash": token_hash, "expires_at": expires_at.isoformat()}) - .execute() + return insert_row( + "password_reset_tokens", + {"user_id": user_id, "token_hash": token_hash, "expires_at": expires_at}, ) - return response.data[0] def get_valid_by_token_hash(token_hash: str) -> Optional[dict]: - client = get_supabase_client() - now = datetime.now(timezone.utc).isoformat() - response = ( - client.table("password_reset_tokens") - .select("*") - .eq("token_hash", token_hash) - .is_("used_at", "null") - .gt("expires_at", now) - .execute() + return fetch_one( + """ + SELECT * + FROM password_reset_tokens + WHERE token_hash = :token_hash + AND used_at IS NULL + AND expires_at > :now + LIMIT 1 + """, + {"token_hash": token_hash, "now": datetime.now(timezone.utc)}, ) - return response.data[0] if response.data else None def mark_used(token_id: int) -> None: - client = get_supabase_client() - now = datetime.now(timezone.utc).isoformat() - client.table("password_reset_tokens").update({"used_at": now}).eq("id", token_id).execute() + execute( + "UPDATE password_reset_tokens SET used_at = :used_at WHERE id = :token_id", + {"used_at": datetime.now(timezone.utc), "token_id": token_id}, + ) diff --git a/server/repositories/priority_rules.py b/server/repositories/priority_rules.py index 0b30726..8968e69 100644 --- a/server/repositories/priority_rules.py +++ b/server/repositories/priority_rules.py @@ -2,41 +2,41 @@ from typing import Optional -from supabase_client import get_supabase_client +from database import fetch_all, fetch_one, fetch_one_in_transaction def get_forced_priority(organization_id: int, service_type: str) -> Optional[str]: - client = get_supabase_client() - response = ( - client.table("org_priority_rules") - .select("forced_priority") - .eq("organization_id", organization_id) - .eq("service_type", service_type) - .execute() + row = fetch_one( + """ + SELECT forced_priority + FROM org_priority_rules + WHERE organization_id = :organization_id AND service_type = :service_type + LIMIT 1 + """, + {"organization_id": organization_id, "service_type": service_type}, ) - return response.data[0]["forced_priority"] if response.data else None + return row["forced_priority"] if row else None def upsert_rule(organization_id: int, service_type: str, forced_priority: str) -> dict: - client = get_supabase_client() - response = ( - client.table("org_priority_rules") - .upsert( - { - "organization_id": organization_id, - "service_type": service_type, - "forced_priority": forced_priority, - }, - on_conflict="organization_id,service_type", - ) - .execute() + return fetch_one_in_transaction( + """ + INSERT INTO org_priority_rules (organization_id, service_type, forced_priority) + VALUES (:organization_id, :service_type, :forced_priority) + ON CONFLICT (organization_id, service_type) + DO UPDATE SET forced_priority = EXCLUDED.forced_priority + RETURNING * + """, + { + "organization_id": organization_id, + "service_type": service_type, + "forced_priority": forced_priority, + }, ) - return response.data[0] def list_by_org(organization_id: int) -> list[dict]: - client = get_supabase_client() - response = ( - client.table("org_priority_rules").select("*").eq("organization_id", organization_id).execute() + return fetch_all( + "SELECT * FROM org_priority_rules WHERE organization_id = :organization_id ORDER BY service_type", + {"organization_id": organization_id}, ) - return response.data or [] diff --git a/server/repositories/technicians.py b/server/repositories/technicians.py index 9b667b8..768b604 100644 --- a/server/repositories/technicians.py +++ b/server/repositories/technicians.py @@ -2,61 +2,56 @@ from typing import Optional -from supabase_client import get_supabase_client +from database import fetch_all, fetch_one, insert_row, update_row + +TECHNICIAN_SELECT = """ + SELECT + t.*, + u.full_name AS user_full_name, + u.email AS user_email, + u.is_active AS user_is_active + FROM technicians t + JOIN users u ON u.id = t.user_id AND u.organization_id = t.organization_id +""" def create_technician(organization_id: int, user_id: int, patch: dict) -> dict: - client = get_supabase_client() - response = ( - client.table("technicians") - .insert({"organization_id": organization_id, "user_id": user_id, **patch}) - .execute() - ) - return response.data[0] + return insert_row("technicians", {"organization_id": organization_id, "user_id": user_id, **patch}) def get_by_id_in_org(technician_id: int, organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("technicians") - .select("*, users!inner(full_name, email, is_active)") - .eq("id", technician_id) - .eq("organization_id", organization_id) - .execute() + row = fetch_one( + TECHNICIAN_SELECT + " WHERE t.id = :technician_id AND t.organization_id = :organization_id", + {"technician_id": technician_id, "organization_id": organization_id}, ) - return response.data[0] if response.data else None + return _with_user(row) if row else None def get_by_user_id(user_id: int, organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("technicians") - .select("*, users!inner(full_name, email, is_active)") - .eq("user_id", user_id) - .eq("organization_id", organization_id) - .execute() + row = fetch_one( + TECHNICIAN_SELECT + " WHERE t.user_id = :user_id AND t.organization_id = :organization_id", + {"user_id": user_id, "organization_id": organization_id}, ) - return response.data[0] if response.data else None + return _with_user(row) if row else None def list_by_org(organization_id: int) -> list[dict]: - client = get_supabase_client() - response = ( - client.table("technicians") - .select("*, users!inner(full_name, email, is_active)") - .eq("organization_id", organization_id) - .execute() + rows = fetch_all( + TECHNICIAN_SELECT + " WHERE t.organization_id = :organization_id ORDER BY t.created_at DESC", + {"organization_id": organization_id}, ) - return response.data or [] + return [_with_user(row) for row in rows] def update(technician_id: int, organization_id: int, patch: dict) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("technicians") - .update(patch) - .eq("id", technician_id) - .eq("organization_id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("technicians", patch, {"id": technician_id, "organization_id": organization_id}) + + +def _with_user(row: dict) -> dict: + row = dict(row) + row["users"] = { + "full_name": row.pop("user_full_name", ""), + "email": row.pop("user_email", ""), + "is_active": row.pop("user_is_active", True), + } + return row diff --git a/server/repositories/users.py b/server/repositories/users.py index 02a1218..a089395 100644 --- a/server/repositories/users.py +++ b/server/repositories/users.py @@ -2,76 +2,49 @@ from typing import Optional -from supabase_client import get_supabase_client +from database import execute, fetch_all, fetch_scalar, insert_row, select_one, update_row def create_user(organization_id: int, email: str, password_hash: str, full_name: str, role: str) -> dict: - client = get_supabase_client() - response = ( - client.table("users") - .insert( - { - "organization_id": organization_id, - "email": email, - "password_hash": password_hash, - "full_name": full_name, - "role": role, - } - ) - .execute() + return insert_row( + "users", + { + "organization_id": organization_id, + "email": email, + "password_hash": password_hash, + "full_name": full_name, + "role": role, + }, ) - return response.data[0] def get_by_email(email: str) -> Optional[dict]: """Email is globally unique, so lookup by email alone is safe (used only at login).""" - client = get_supabase_client() - response = client.table("users").select("*").eq("email", email).execute() - return response.data[0] if response.data else None + return select_one("users", {"email": email}) def get_by_id_in_org(user_id: int, organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("users") - .select("*") - .eq("id", user_id) - .eq("organization_id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return select_one("users", {"id": user_id, "organization_id": organization_id}) def list_by_org(organization_id: int) -> list[dict]: - client = get_supabase_client() - response = client.table("users").select("*").eq("organization_id", organization_id).execute() - return response.data or [] + return fetch_all( + "SELECT * FROM users WHERE organization_id = :organization_id ORDER BY created_at DESC", + {"organization_id": organization_id}, + ) def count_by_org_and_role(organization_id: int, role: str) -> int: - client = get_supabase_client() - response = ( - client.table("users") - .select("id", count="exact") - .eq("organization_id", organization_id) - .eq("role", role) - .execute() + count = fetch_scalar( + "SELECT COUNT(*) FROM users WHERE organization_id = :organization_id AND role = :role", + {"organization_id": organization_id, "role": role}, ) - return response.count or 0 + return int(count or 0) def update_password(user_id: int, password_hash: str) -> None: - client = get_supabase_client() - client.table("users").update({"password_hash": password_hash}).eq("id", user_id).execute() + execute("UPDATE users SET password_hash = :password_hash WHERE id = :user_id", {"password_hash": password_hash, "user_id": user_id}) def update_role_and_status(user_id: int, organization_id: int, patch: dict) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("users") - .update(patch) - .eq("id", user_id) - .eq("organization_id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("users", patch, {"id": user_id, "organization_id": organization_id}) diff --git a/server/repositories/work_order_events.py b/server/repositories/work_order_events.py index 22720f1..e88cea2 100644 --- a/server/repositories/work_order_events.py +++ b/server/repositories/work_order_events.py @@ -1,6 +1,6 @@ """Data access for the work order audit log (RF-20).""" -from supabase_client import get_supabase_client +from database import fetch_all, insert_row def create_event( @@ -12,33 +12,27 @@ def create_event( to_status: str | None = None, notes: str | None = None, ) -> dict: - client = get_supabase_client() - response = ( - client.table("work_order_events") - .insert( - { - "organization_id": organization_id, - "work_order_id": work_order_id, - "event_type": event_type, - "actor_user_id": actor_user_id, - "from_status": from_status, - "to_status": to_status, - "notes": notes, - } - ) - .execute() + return insert_row( + "work_order_events", + { + "organization_id": organization_id, + "work_order_id": work_order_id, + "event_type": event_type, + "actor_user_id": actor_user_id, + "from_status": from_status, + "to_status": to_status, + "notes": notes, + }, ) - return response.data[0] def list_for_work_order(organization_id: int, work_order_id: int) -> list[dict]: - client = get_supabase_client() - response = ( - client.table("work_order_events") - .select("*") - .eq("organization_id", organization_id) - .eq("work_order_id", work_order_id) - .order("created_at", desc=True) - .execute() + return fetch_all( + """ + SELECT * + FROM work_order_events + WHERE organization_id = :organization_id AND work_order_id = :work_order_id + ORDER BY created_at DESC + """, + {"organization_id": organization_id, "work_order_id": work_order_id}, ) - return response.data or [] diff --git a/server/repositories/work_orders.py b/server/repositories/work_orders.py index bac5997..55ffa18 100644 --- a/server/repositories/work_orders.py +++ b/server/repositories/work_orders.py @@ -3,37 +3,22 @@ from datetime import date from typing import Optional -from supabase_client import get_supabase_client +from database import fetch_all, fetch_one, fetch_scalar, insert_row, update_row def create(organization_id: int, patch: dict) -> dict: - client = get_supabase_client() - response = client.table("work_orders").insert({"organization_id": organization_id, **patch}).execute() - return response.data[0] + return insert_row("work_orders", {"organization_id": organization_id, **patch}) def get_by_id_in_org(work_order_id: int, organization_id: int) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("work_orders") - .select("*") - .eq("id", work_order_id) - .eq("organization_id", organization_id) - .execute() + return fetch_one( + "SELECT * FROM work_orders WHERE id = :work_order_id AND organization_id = :organization_id", + {"work_order_id": work_order_id, "organization_id": organization_id}, ) - return response.data[0] if response.data else None def update(work_order_id: int, organization_id: int, patch: dict) -> Optional[dict]: - client = get_supabase_client() - response = ( - client.table("work_orders") - .update(patch) - .eq("id", work_order_id) - .eq("organization_id", organization_id) - .execute() - ) - return response.data[0] if response.data else None + return update_row("work_orders", patch, {"id": work_order_id, "organization_id": organization_id}) def list_filtered( @@ -45,66 +30,83 @@ def list_filtered( date_to: Optional[date] = None, ) -> list[dict]: """RF-21: combined filter by status, technician, customer, date range.""" - client = get_supabase_client() - query = client.table("work_orders").select("*").eq("organization_id", organization_id) + where = ["organization_id = :organization_id"] + params = {"organization_id": organization_id} if status: - query = query.eq("status", status) + where.append("status = :status") + params["status"] = status if technician_id: - query = query.eq("assigned_technician_id", technician_id) + where.append("assigned_technician_id = :technician_id") + params["technician_id"] = technician_id if customer_name: - query = query.ilike("customer_name", f"%{customer_name}%") + where.append("customer_name ILIKE :customer_name") + params["customer_name"] = f"%{customer_name}%" if date_from: - query = query.gte("created_at", date_from.isoformat()) + where.append("created_at >= :date_from") + params["date_from"] = date_from if date_to: - query = query.lte("created_at", date_to.isoformat()) + where.append("created_at <= :date_to") + params["date_to"] = date_to - response = query.order("created_at", desc=True).execute() - return response.data or [] + return fetch_all( + f"SELECT * FROM work_orders WHERE {' AND '.join(where)} ORDER BY created_at DESC", + params, + ) def list_for_technician(organization_id: int, technician_id: int) -> list[dict]: """RF-22: technician's assigned work orders ordered by priority.""" - client = get_supabase_client() - priority_order = {"emergency": 0, "high": 1, "medium": 2, "low": 3} - response = ( - client.table("work_orders") - .select("*") - .eq("organization_id", organization_id) - .eq("assigned_technician_id", technician_id) - .in_("status", ["open", "in_progress"]) - .execute() + return fetch_all( + """ + SELECT * + FROM work_orders + WHERE organization_id = :organization_id + AND assigned_technician_id = :technician_id + AND status IN ('open', 'in_progress') + ORDER BY CASE priority + WHEN 'emergency' THEN 0 + WHEN 'high' THEN 1 + WHEN 'medium' THEN 2 + WHEN 'low' THEN 3 + ELSE 99 + END, created_at DESC + """, + {"organization_id": organization_id, "technician_id": technician_id}, ) - rows = response.data or [] - return sorted(rows, key=lambda r: priority_order.get(r.get("priority"), 99)) def counts_by_status(organization_id: int) -> dict[str, int]: - client = get_supabase_client() - response = ( - client.table("work_orders").select("status").eq("organization_id", organization_id).execute() + rows = fetch_all( + """ + SELECT status, COUNT(*) AS count + FROM work_orders + WHERE organization_id = :organization_id + GROUP BY status + """, + {"organization_id": organization_id}, ) - rows = response.data or [] counts = {"open": 0, "in_progress": 0, "completed": 0, "cancelled": 0} for row in rows: status_value = row.get("status") if status_value in counts: - counts[status_value] += 1 + counts[status_value] = int(row.get("count") or 0) return counts def count_sla_at_risk(organization_id: int) -> int: from datetime import datetime, timedelta, timezone - client = get_supabase_client() - soon = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat() - response = ( - client.table("work_orders") - .select("id", count="exact") - .eq("organization_id", organization_id) - .in_("status", ["open", "in_progress"]) - .not_.is_("sla_due_at", "null") - .lte("sla_due_at", soon) - .execute() + soon = datetime.now(timezone.utc) + timedelta(hours=2) + count = fetch_scalar( + """ + SELECT COUNT(*) + FROM work_orders + WHERE organization_id = :organization_id + AND status IN ('open', 'in_progress') + AND sla_due_at IS NOT NULL + AND sla_due_at <= :soon + """, + {"organization_id": organization_id, "soon": soon}, ) - return response.count or 0 + return int(count or 0) diff --git a/server/requirements.txt b/server/requirements.txt index ba1182e..670e7ac 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] python-dotenv pydantic[email] -supabase +boto3 python-jose[cryptography] passlib[bcrypt] # passlib 1.7.4 (latest release) is incompatible with bcrypt>=4.1 -- pin below 4.1 diff --git a/server/routers/work_orders.py b/server/routers/work_orders.py index 51d70fa..76a9aac 100644 --- a/server/routers/work_orders.py +++ b/server/routers/work_orders.py @@ -221,7 +221,7 @@ def add_attachment( ): """RF-19: attach evidence (photo/document) metadata to a work order. The file itself is expected to already be uploaded to object storage - (e.g. Supabase Storage) by the client; this endpoint records the resulting + (e.g. S3/R2) by the client; this endpoint records the resulting URL against the work order.""" _get_accessible_work_order(work_order_id, current_user, organization) row = attachments_repo.create(organization["id"], work_order_id, current_user.id, payload.dict()) @@ -246,7 +246,7 @@ async def upload_attachment( current_user: User = Depends(get_current_user), organization: dict = Depends(get_current_organization), ): - """RF-19: upload evidence to Supabase Storage and record attachment metadata.""" + """RF-19: upload evidence to object storage and record attachment metadata.""" _get_accessible_work_order(work_order_id, current_user, organization) uploaded = await attachment_storage_service.upload_work_order_attachment_file( organization["id"], work_order_id, file diff --git a/server/schema.sql b/server/schema.sql index 98840e1..c100d29 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -1,5 +1,5 @@ -- TechSync SaaS Database Schema --- Run this SQL in your Supabase SQL Editor (or via Alembic migrations, see server/alembic/) +-- Run this SQL via Alembic migrations or any managed Postgres SQL console. -- -- Multi-tenancy model (RF-05): -- Every tenant-scoped table carries an `organization_id` column. The backend @@ -7,19 +7,13 @@ -- layer. Row Level Security is additionally enabled on every tenant table as a -- database-level backstop (RNF-05). -- --- NOTE on RLS + Supabase service_role key: --- The backend uses the Supabase service_role key for trusted server-side writes --- (e.g. creating an organization's first admin), and the service_role key --- bypasses RLS by design. That means, for this POC, application-layer scoping --- in the repository layer is the PRIMARY enforcement mechanism, and RLS is the --- secondary/backstop layer, active and ready for the moment any code path uses --- the anon/authenticated key instead (e.g. a future direct-from-client Supabase --- access path). To make RLS effective in that scenario, sign the JWT handed to --- PostgREST with the Supabase project's JWT secret and include an --- `organization_id` claim, then call `supabase.postgrest.auth(token)` before --- querying -- PostgREST will expose the claim as --- current_setting('request.jwt.claims', true)::json->>'organization_id', which --- is what the policies below read. +-- NOTE on RLS + direct backend access: +-- The FastAPI backend connects directly to Postgres through SQLAlchemy and +-- enforces tenant scoping in the repository layer by filtering every tenant +-- query on organization_id. RLS remains enabled as a database-level backstop. +-- For RLS-aware access paths, set request.jwt.claims for the current database +-- session with an organization_id claim before querying; the policies below +-- read current_setting('request.jwt.claims', true)::json->>'organization_id'. -- ===================================================================== -- Extensions @@ -27,7 +21,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; -- ===================================================================== --- Helper: read organization_id out of the PostgREST JWT claims +-- Helper: read organization_id out of the database session claims -- ===================================================================== CREATE OR REPLACE FUNCTION techsync_current_org_id() RETURNS BIGINT AS $$ diff --git a/server/services/attachment_storage_service.py b/server/services/attachment_storage_service.py index e9a42bd..e26e037 100644 --- a/server/services/attachment_storage_service.py +++ b/server/services/attachment_storage_service.py @@ -1,13 +1,13 @@ -"""Supabase Storage upload helpers for work order attachments (RF-19).""" +"""S3-compatible object storage upload helpers for work order attachments (RF-19).""" from pathlib import Path import re +from urllib.parse import quote from uuid import uuid4 from fastapi import HTTPException, UploadFile, status from core.config import settings -from supabase_client import get_supabase_client ALLOWED_ATTACHMENT_CONTENT_TYPES = { "image/jpeg": ".jpg", @@ -18,6 +18,10 @@ } +class StorageNotConfigured(Exception): + """Raised when S3-compatible storage settings are missing.""" + + async def upload_work_order_attachment_file( organization_id: int, work_order_id: int, file: UploadFile ) -> dict: @@ -28,18 +32,18 @@ async def upload_work_order_attachment_file( _validate_upload(file_name, content_type, content) storage_path = _build_storage_path(organization_id, work_order_id, file_name, content_type) - bucket_name = settings.SUPABASE_ATTACHMENTS_BUCKET - client = get_supabase_client() + bucket_name = _required_setting(settings.STORAGE_BUCKET, "STORAGE_BUCKET") + public_base_url = _required_setting(settings.STORAGE_PUBLIC_BASE_URL, "STORAGE_PUBLIC_BASE_URL") + client = _get_storage_client() try: - bucket = client.storage.from_(bucket_name) - bucket.upload( - storage_path, - content, - file_options={"content-type": content_type, "upsert": "false"}, + client.put_object( + Bucket=bucket_name, + Key=storage_path, + Body=content, + ContentType=content_type, ) - file_url = bucket.get_public_url(storage_path) - except Exception as exc: # pragma: no cover - exact SDK exceptions vary by version. + except Exception as exc: # pragma: no cover - exact SDK exceptions vary by provider/version. raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Attachment upload failed", @@ -47,11 +51,38 @@ async def upload_work_order_attachment_file( return { "file_name": file_name, - "file_url": _normalize_public_url(file_url), + "file_url": _public_url(public_base_url, storage_path), "content_type": content_type, } +def _get_storage_client(): + access_key = _required_setting(settings.STORAGE_ACCESS_KEY_ID, "STORAGE_ACCESS_KEY_ID") + secret_key = _required_setting(settings.STORAGE_SECRET_ACCESS_KEY, "STORAGE_SECRET_ACCESS_KEY") + + try: + import boto3 # type: ignore + except ImportError as exc: # pragma: no cover - exercised by deployment packaging, not unit tests. + raise StorageNotConfigured("boto3 is required for attachment storage") from exc + + client_kwargs = { + "service_name": "s3", + "aws_access_key_id": access_key, + "aws_secret_access_key": secret_key, + "region_name": settings.STORAGE_REGION, + } + if settings.STORAGE_ENDPOINT_URL: + client_kwargs["endpoint_url"] = settings.STORAGE_ENDPOINT_URL + + return boto3.client(**client_kwargs) + + +def _required_setting(value: str | None, name: str) -> str: + if not value: + raise StorageNotConfigured(f"{name} is not configured") + return value + + def _validate_upload(file_name: str, content_type: str, content: bytes) -> None: if not content: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Attachment file is empty") @@ -104,10 +135,5 @@ def _slug_file_stem(stem: str) -> str: return slug -def _normalize_public_url(file_url) -> str: - if isinstance(file_url, str): - return file_url - if isinstance(file_url, dict): - return file_url.get("publicUrl") or file_url.get("public_url") or "" - public_url = getattr(file_url, "public_url", None) or getattr(file_url, "publicUrl", None) - return public_url or str(file_url) +def _public_url(public_base_url: str, storage_path: str) -> str: + return f"{public_base_url.rstrip('/')}/{quote(storage_path, safe='/')}" diff --git a/server/supabase_client.py b/server/supabase_client.py deleted file mode 100644 index 16ac6d6..0000000 --- a/server/supabase_client.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Supabase client helper for TechSync. - -If SUPABASE_URL / SUPABASE_KEY are missing OR the supabase library -is not installed, SupabaseNotConfigured is raised so the rest of the -app can fall back to mock data. -""" - -import os -from typing import Optional - -try: - from supabase import create_client, Client # type: ignore -except ImportError: - # Library is not installed – we will treat this as "not configured" - create_client = None - Client = object # type: ignore - - -class SupabaseNotConfigured(Exception): - """Raised when Supabase URL, key, or library are missing.""" - - -def get_supabase_client() -> "Client": - url: Optional[str] = os.getenv("SUPABASE_URL") - key: Optional[str] = os.getenv("SUPABASE_KEY") - - # If env vars or library are missing, we signal that Supabase is not ready - if not url or not key or create_client is None: - raise SupabaseNotConfigured( - "Supabase is not configured (missing env vars or supabase library)." - ) - - return create_client(url, key) diff --git a/server/tests/test_attachment_storage_service.py b/server/tests/test_attachment_storage_service.py index f70f7b3..161c9c7 100644 --- a/server/tests/test_attachment_storage_service.py +++ b/server/tests/test_attachment_storage_service.py @@ -19,34 +19,20 @@ async def read(self, size=-1): return self._content[:size] -class FakeBucket: +class FakeStorageClient: def __init__(self): - self.upload_calls = [] + self.put_calls = [] - def upload(self, path, content, file_options=None): - self.upload_calls.append((path, content, file_options)) - - def get_public_url(self, path): - return f"https://cdn.example.com/{path}" - - -class FakeStorage: - def __init__(self, bucket): - self.bucket = bucket - self.bucket_names = [] - - def from_(self, bucket_name): - self.bucket_names.append(bucket_name) - return self.bucket + def put_object(self, **kwargs): + self.put_calls.append(kwargs) def test_upload_work_order_attachment_file_stores_file_and_returns_metadata(monkeypatch): - bucket = FakeBucket() - storage = FakeStorage(bucket) - fake_client = SimpleNamespace(storage=storage) + fake_client = FakeStorageClient() - monkeypatch.setattr(attachment_storage_service, "get_supabase_client", lambda: fake_client) - monkeypatch.setattr(attachment_storage_service.settings, "SUPABASE_ATTACHMENTS_BUCKET", "wo-files") + monkeypatch.setattr(attachment_storage_service, "_get_storage_client", lambda: fake_client) + monkeypatch.setattr(attachment_storage_service.settings, "STORAGE_BUCKET", "wo-files") + monkeypatch.setattr(attachment_storage_service.settings, "STORAGE_PUBLIC_BASE_URL", "https://files.example.com") monkeypatch.setattr(attachment_storage_service.settings, "ATTACHMENT_MAX_BYTES", 1024) monkeypatch.setattr(attachment_storage_service, "uuid4", lambda: SimpleNamespace(hex="abc123")) @@ -55,17 +41,17 @@ def test_upload_work_order_attachment_file_stores_file_and_returns_metadata(monk attachment_storage_service.upload_work_order_attachment_file(42, 99, upload) ) - assert storage.bucket_names == ["wo-files"] - assert bucket.upload_calls == [ - ( - "org-42/work-order-99/abc123-Before-Repair.jpg", - b"image-bytes", - {"content-type": "image/jpeg", "upsert": "false"}, - ) + assert fake_client.put_calls == [ + { + "Bucket": "wo-files", + "Key": "org-42/work-order-99/abc123-Before-Repair.jpg", + "Body": b"image-bytes", + "ContentType": "image/jpeg", + } ] assert result == { "file_name": "Before Repair.JPG", - "file_url": "https://cdn.example.com/org-42/work-order-99/abc123-Before-Repair.jpg", + "file_url": "https://files.example.com/org-42/work-order-99/abc123-Before-Repair.jpg", "content_type": "image/jpeg", } @@ -100,3 +86,13 @@ def test_upload_rejects_extension_mismatch(monkeypatch): assert exc.value.status_code == 400 assert "extension" in exc.value.detail + + +def test_upload_requires_storage_configuration(monkeypatch): + monkeypatch.setattr(attachment_storage_service.settings, "STORAGE_BUCKET", None) + monkeypatch.setattr(attachment_storage_service.settings, "STORAGE_PUBLIC_BASE_URL", "https://files.example.com") + monkeypatch.setattr(attachment_storage_service.settings, "ATTACHMENT_MAX_BYTES", 1024) + upload = FakeUploadFile("photo.jpg", "image/jpeg", b"jpg") + + with pytest.raises(attachment_storage_service.StorageNotConfigured): + asyncio.run(attachment_storage_service.upload_work_order_attachment_file(1, 2, upload)) diff --git a/server/tests/test_tenant_isolation.py b/server/tests/test_tenant_isolation.py index 4e663af..d2e1bcd 100644 --- a/server/tests/test_tenant_isolation.py +++ b/server/tests/test_tenant_isolation.py @@ -1,16 +1,11 @@ """ -Verifies that every repository query is scoped by organization_id (RF-05, -RNF-05) at the application layer -- the primary enforcement layer for this -POC, backed by the RLS policies in schema.sql as a database-level backstop -(see server/tests/test_rls... exercised manually against Postgres, and -schema.sql's policy comments for how that layer works). - -These tests fake the supabase-py query builder chain and assert that -`.eq("organization_id", ...)` is always called with the caller's org id, -never with any other tenant's id. +Verifies that repository operations keep tenant scoping in the application +layer (RF-05/RNF-05). With direct Postgres access, organization_id filters are +the primary enforcement path, so these tests assert that repository SQL/helper +calls carry the caller's organization_id. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from repositories import technicians as technicians_repo from repositories import users as users_repo @@ -18,144 +13,61 @@ from repositories import work_orders as work_orders_repo -class FakeQuery: - """Minimal stand-in for the supabase-py fluent query builder that records - every .eq()/.filter() call so tests can assert on them.""" +def test_list_filtered_scopes_by_organization_id(): + with patch("repositories.work_orders.fetch_all", return_value=[]) as mock_fetch: + work_orders_repo.list_filtered(organization_id=42) - def __init__(self, table_name, store): - self.table_name = table_name - self.store = store - self.calls = [] + sql, params = mock_fetch.call_args.args + assert "organization_id = :organization_id" in sql + assert params["organization_id"] == 42 - def select(self, *args, **kwargs): - return self - def insert(self, *args, **kwargs): - self.calls.append(("insert", args, kwargs)) - return self +def test_get_by_id_in_org_always_filters_by_caller_org_not_just_row_id(): + with patch("repositories.work_orders.fetch_one", return_value=None) as mock_fetch: + work_orders_repo.get_by_id_in_org(work_order_id=99, organization_id=7) - def update(self, *args, **kwargs): - self.calls.append(("update", args, kwargs)) - return self + sql, params = mock_fetch.call_args.args + assert "id = :work_order_id" in sql + assert "organization_id = :organization_id" in sql + assert params == {"work_order_id": 99, "organization_id": 7} - def eq(self, field, value): - self.calls.append(("eq", field, value)) - return self - def ilike(self, *args, **kwargs): - return self +def test_update_requires_both_id_and_organization_id(): + with patch("repositories.work_orders.update_row", return_value=None) as mock_update: + work_orders_repo.update(work_order_id=5, organization_id=3, patch={"status": "completed"}) - def gte(self, *args, **kwargs): - return self + table, patch_payload, where = mock_update.call_args.args + assert table == "work_orders" + assert patch_payload == {"status": "completed"} + assert where == {"id": 5, "organization_id": 3} - def lte(self, *args, **kwargs): - return self - def in_(self, *args, **kwargs): - return self +def test_users_list_by_org_is_scoped(): + with patch("repositories.users.fetch_all", return_value=[]) as mock_fetch: + users_repo.list_by_org(organization_id=11) - def is_(self, *args, **kwargs): - return self + sql, params = mock_fetch.call_args.args + assert "WHERE organization_id = :organization_id" in sql + assert params["organization_id"] == 11 - def order(self, *args, **kwargs): - return self - def execute(self): - response = MagicMock() - response.data = [] - response.count = 0 - return response +def test_technicians_get_by_id_scoped_to_org(): + with patch("repositories.technicians.fetch_one", return_value=None) as mock_fetch: + technicians_repo.get_by_id_in_org(technician_id=8, organization_id=2) + sql, params = mock_fetch.call_args.args + assert "t.id = :technician_id" in sql + assert "t.organization_id = :organization_id" in sql + assert params == {"technician_id": 8, "organization_id": 2} -class FakeSupabaseClient: - def __init__(self): - self.queries: list[FakeQuery] = [] - def table(self, name): - q = FakeQuery(name, self) - self.queries.append(q) - return q - - -def _eq_calls(query: FakeQuery): - return [(field, value) for kind, field, value in query.calls if kind == "eq"] - - -@patch("repositories.work_orders.get_supabase_client") -def test_list_filtered_scopes_by_organization_id(mock_get_client): - fake_client = FakeSupabaseClient() - mock_get_client.return_value = fake_client - - work_orders_repo.list_filtered(organization_id=42) - - query = fake_client.queries[-1] - assert ("organization_id", 42) in _eq_calls(query) - - -@patch("repositories.work_orders.get_supabase_client") -def test_get_by_id_in_org_always_filters_by_caller_org_not_just_row_id(mock_get_client): - fake_client = FakeSupabaseClient() - mock_get_client.return_value = fake_client - - work_orders_repo.get_by_id_in_org(work_order_id=99, organization_id=7) - - query = fake_client.queries[-1] - calls = _eq_calls(query) - assert ("id", 99) in calls - assert ("organization_id", 7) in calls - - -@patch("repositories.work_orders.get_supabase_client") -def test_update_requires_both_id_and_organization_id(mock_get_client): - fake_client = FakeSupabaseClient() - mock_get_client.return_value = fake_client - - work_orders_repo.update(work_order_id=5, organization_id=3, patch={"status": "completed"}) - - query = fake_client.queries[-1] - calls = _eq_calls(query) - assert ("id", 5) in calls - assert ("organization_id", 3) in calls - - -@patch("repositories.users.get_supabase_client") -def test_users_list_by_org_is_scoped(mock_get_client): - fake_client = FakeSupabaseClient() - mock_get_client.return_value = fake_client - - users_repo.list_by_org(organization_id=11) - - query = fake_client.queries[-1] - assert ("organization_id", 11) in _eq_calls(query) - - -@patch("repositories.technicians.get_supabase_client") -def test_technicians_get_by_id_scoped_to_org(mock_get_client): - fake_client = FakeSupabaseClient() - mock_get_client.return_value = fake_client - - technicians_repo.get_by_id_in_org(technician_id=8, organization_id=2) - - query = fake_client.queries[-1] - calls = _eq_calls(query) - assert ("id", 8) in calls - assert ("organization_id", 2) in calls - - -@patch("repositories.work_order_events.get_supabase_client") -def test_events_are_written_with_org_id(mock_get_client): - class InsertingFakeQuery(FakeQuery): - def execute(self): - response = MagicMock() - inserted_payload = self.calls[0][1][0] - response.data = [inserted_payload] - return response - - fake_client = FakeSupabaseClient() - fake_client.table = lambda name: InsertingFakeQuery(name, fake_client) - mock_get_client.return_value = fake_client - - event = events_repo.create_event(organization_id=6, work_order_id=1, event_type="created") +def test_events_are_written_with_org_id(): + with patch("repositories.work_order_events.insert_row") as mock_insert: + mock_insert.side_effect = lambda table, payload: payload + event = events_repo.create_event(organization_id=6, work_order_id=1, event_type="created") + table, payload = mock_insert.call_args.args + assert table == "work_order_events" + assert payload["organization_id"] == 6 + assert payload["work_order_id"] == 1 assert event["organization_id"] == 6 - assert event["work_order_id"] == 1