M1 security: close the password backdoor, apply re-review, pin E.164

Acts on an adversarial review of the M1 auth and authorization code.
Five findings fixed; the rest recorded in SECURITY-FINDINGS.md as the
M1 exit criteria rather than left in a tool transcript.

- auth: serve /phone-number/request-password-reset, /phone-number/
  reset-password and /sign-in/phone-number as 404. better-auth's
  phoneNumber() registers all three unconditionally -- they are NOT
  gated on emailAndPassword.enabled:false. Left live they form a
  silent second credential path: request-password-reset stores an OTP
  and sends no SMS (sendPasswordResetOTP was never configured, so the
  owner is never told), reset-password mints a bcrypt credential row,
  and sign-in/phone-number then accepts it forever with no OTP. The
  OTP gate still applies, so this is not remote unauthenticated
  takeover -- it converts one momentary OTP compromise into permanent
  access the victim cannot see or rotate.

- auth: drop bearer(). It accepts the plaintext sessions.token column
  as an Authorization credential, making any single leaked row a
  replayable login. The mobile client it was added for is hypothetical.

- auth: pin E.164 via phoneNumberValidator, and add toE164/isE164 to
  @linkder/shared. phone is UNIQUE and bans are per-account, so
  "+34600111222" and "0034600111222" being separately storable meant
  one handset could hold two accounts and a ban was escapable by
  retyping. 15 tests.

- auth: NEXT_PUBLIC_APP_URL now throws in production instead of
  falling back to localhost, which was silently dropping Secure and
  the __Secure- prefix from the production session cookie.

- pro.upsertProfile: actually apply requiresReReview. It was computed,
  returned to the client and never acted on, so a verified plumber
  could become a verified electrician in another city by ignoring a
  response flag. Now demotes to pending in the same transaction and
  audits it. Trade changes count as material (they did not before) --
  the licence is per-trade. Needed a verified -> pending edge in
  VERIFICATION_GRAPH, which did not exist.

Removed two untracked scratch repro files. The impersonation repro
depended on bearer() for transport and no longer applies as written;
the underlying finding (resolveSession drops impersonatedBy, so admin
actions are audited as the victim) is open and documented.

typecheck, lint, build clean; 127 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-21 01:19:32 -04:00
co-authored by Claude Opus 5
parent cebeda7f4c
commit c617bc9687
26 changed files with 2012 additions and 52 deletions
@@ -0,0 +1,361 @@
CREATE TYPE "public"."booking_status" AS ENUM('scheduled', 'in_progress', 'awaiting_confirmation', 'completed', 'cancelled', 'disputed');--> statement-breakpoint
CREATE TYPE "public"."credential_kind" AS ENUM('id', 'licence', 'insurance');--> statement-breakpoint
CREATE TYPE "public"."job_status" AS ENUM('open', 'matched', 'booked', 'completed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."media_kind" AS ENUM('photo', 'work_sample');--> statement-breakpoint
CREATE TYPE "public"."payment_status" AS ENUM('pending', 'held', 'released', 'refunded', 'partially_refunded', 'failed');--> statement-breakpoint
CREATE TYPE "public"."quote_kind" AS ENUM('fixed', 'hourly');--> statement-breakpoint
CREATE TYPE "public"."quote_status" AS ENUM('sent', 'accepted', 'declined', 'withdrawn', 'expired');--> statement-breakpoint
CREATE TYPE "public"."request_status" AS ENUM('pending', 'accepted', 'declined', 'expired');--> statement-breakpoint
CREATE TYPE "public"."review_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
CREATE TYPE "public"."swipe_direction" AS ENUM('left', 'right');--> statement-breakpoint
CREATE TYPE "public"."urgency" AS ENUM('now', 'this_week', 'flexible');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('client', 'pro', 'admin');--> statement-breakpoint
CREATE TYPE "public"."verification_status" AS ENUM('draft', 'pending', 'verified', 'rejected', 'suspended');--> statement-breakpoint
CREATE TABLE "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"issuer" text NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" uuid NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "accounts_issuer_account_unique" UNIQUE("issuer","account_id")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"token" text NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"impersonated_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"phone" text,
"phone_verified" boolean DEFAULT false,
"phone_verified_at" timestamp with time zone,
"image" text,
"role" "user_role" DEFAULT 'client' NOT NULL,
"banned" boolean DEFAULT false,
"ban_reason" text,
"ban_expires" timestamp with time zone,
"last_active_at" timestamp with time zone DEFAULT now(),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_phone_unique" UNIQUE("phone")
);
--> statement-breakpoint
CREATE TABLE "verifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"icon" text,
"position" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "credentials" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL,
"kind" "credential_kind" NOT NULL,
"file_key" text NOT NULL,
"issuer" text,
"expires_at" timestamp with time zone,
"review_status" "review_status" DEFAULT 'pending' NOT NULL,
"reviewed_by" uuid,
"reviewed_at" timestamp with time zone,
"review_notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pro_availability" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL,
"weekday" integer NOT NULL,
"start_minute" integer NOT NULL,
"end_minute" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pro_categories" (
"pro_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
CONSTRAINT "pro_categories_pro_id_category_id_pk" PRIMARY KEY("pro_id","category_id")
);
--> statement-breakpoint
CREATE TABLE "pro_media" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL,
"url" text NOT NULL,
"kind" "media_kind" DEFAULT 'photo' NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pro_profiles" (
"user_id" uuid PRIMARY KEY NOT NULL,
"headline" text NOT NULL,
"bio" text NOT NULL,
"hourly_rate_cents" integer NOT NULL,
"years_experience" integer DEFAULT 0 NOT NULL,
"base_location" geography(Point,4326) NOT NULL,
"service_radius_m" integer DEFAULT 15000 NOT NULL,
"verification_status" "verification_status" DEFAULT 'draft' NOT NULL,
"verified_at" timestamp with time zone,
"suspended_reason" text,
"is_accepting_jobs" boolean DEFAULT true NOT NULL,
"rating_avg" numeric(3, 2),
"rating_count" integer DEFAULT 0 NOT NULL,
"completed_jobs" integer DEFAULT 0 NOT NULL,
"response_rate" numeric(4, 3),
"avg_response_minutes" integer,
"stripe_account_id" text,
"stripe_payouts_enabled" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pro_profiles_stripe_account_id_unique" UNIQUE("stripe_account_id")
);
--> statement-breakpoint
CREATE TABLE "verification_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pro_id" uuid NOT NULL,
"provider" text DEFAULT 'didit' NOT NULL,
"external_id" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"decision" text,
"raw_payload" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "verification_sessions_external_id_unique" UNIQUE("external_id")
);
--> statement-breakpoint
CREATE TABLE "jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"photos" text[] DEFAULT '{}'::text[] NOT NULL,
"urgency" "urgency" DEFAULT 'flexible' NOT NULL,
"budget_min_cents" integer,
"budget_max_cents" integer,
"location" geography(Point,4326) NOT NULL,
"address_text" text NOT NULL,
"status" "job_status" DEFAULT 'open' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "matches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"request_id" uuid NOT NULL,
"job_id" uuid NOT NULL,
"pro_id" uuid NOT NULL,
"client_id" uuid NOT NULL,
"last_message_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "matches_request_id_unique" UNIQUE("request_id")
);
--> statement-breakpoint
CREATE TABLE "requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job_id" uuid NOT NULL,
"pro_id" uuid NOT NULL,
"status" "request_status" DEFAULT 'pending' NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"responded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "requests_job_pro_unique" UNIQUE("job_id","pro_id")
);
--> statement-breakpoint
CREATE TABLE "swipes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job_id" uuid NOT NULL,
"pro_id" uuid NOT NULL,
"direction" "swipe_direction" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "swipes_job_pro_unique" UNIQUE("job_id","pro_id")
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_id" uuid NOT NULL,
"sender_id" uuid NOT NULL,
"body" text NOT NULL,
"attachments" text[] DEFAULT '{}'::text[] NOT NULL,
"read_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "bookings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_id" uuid NOT NULL,
"quote_id" uuid NOT NULL,
"scheduled_start" timestamp with time zone NOT NULL,
"scheduled_end" timestamp with time zone NOT NULL,
"status" "booking_status" DEFAULT 'scheduled' NOT NULL,
"pro_completed_at" timestamp with time zone,
"client_confirmed_at" timestamp with time zone,
"cancelled_at" timestamp with time zone,
"cancelled_by" uuid,
"cancellation_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"booking_id" uuid NOT NULL,
"stripe_payment_intent_id" text,
"stripe_transfer_id" text,
"stripe_refund_id" text,
"amount_cents" integer NOT NULL,
"platform_fee_cents" integer NOT NULL,
"platform_fee_bps" integer NOT NULL,
"refunded_cents" integer DEFAULT 0 NOT NULL,
"currency" text DEFAULT 'eur' NOT NULL,
"status" "payment_status" DEFAULT 'pending' NOT NULL,
"captured_at" timestamp with time zone,
"released_at" timestamp with time zone,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "payments_booking_id_unique" UNIQUE("booking_id"),
CONSTRAINT "payments_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id"),
CONSTRAINT "payments_stripe_transfer_id_unique" UNIQUE("stripe_transfer_id")
);
--> statement-breakpoint
CREATE TABLE "processed_stripe_events" (
"event_id" text PRIMARY KEY NOT NULL,
"type" text NOT NULL,
"processed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "quotes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"match_id" uuid NOT NULL,
"kind" "quote_kind" DEFAULT 'fixed' NOT NULL,
"amount_cents" integer NOT NULL,
"hours_estimate" integer,
"scope" text NOT NULL,
"status" "quote_status" DEFAULT 'sent' NOT NULL,
"valid_until" timestamp with time zone NOT NULL,
"responded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reviews" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"booking_id" uuid NOT NULL,
"author_id" uuid NOT NULL,
"subject_id" uuid NOT NULL,
"rating" integer NOT NULL,
"body" text NOT NULL,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "reviews_booking_author_unique" UNIQUE("booking_id","author_id")
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_id" uuid,
"action" text NOT NULL,
"entity" text NOT NULL,
"entity_id" text,
"metadata" jsonb,
"ip" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_impersonated_by_users_id_fk" FOREIGN KEY ("impersonated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credentials" ADD CONSTRAINT "credentials_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pro_availability" ADD CONSTRAINT "pro_availability_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pro_categories" ADD CONSTRAINT "pro_categories_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pro_categories" ADD CONSTRAINT "pro_categories_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pro_media" ADD CONSTRAINT "pro_media_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pro_profiles" ADD CONSTRAINT "pro_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "verification_sessions" ADD CONSTRAINT "verification_sessions_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_client_id_users_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_request_id_requests_id_fk" FOREIGN KEY ("request_id") REFERENCES "public"."requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "matches" ADD CONSTRAINT "matches_client_id_users_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "requests" ADD CONSTRAINT "requests_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "requests" ADD CONSTRAINT "requests_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "swipes" ADD CONSTRAINT "swipes_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "swipes" ADD CONSTRAINT "swipes_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_quote_id_quotes_id_fk" FOREIGN KEY ("quote_id") REFERENCES "public"."quotes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_cancelled_by_users_id_fk" FOREIGN KEY ("cancelled_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payments" ADD CONSTRAINT "payments_booking_id_bookings_id_fk" FOREIGN KEY ("booking_id") REFERENCES "public"."bookings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quotes" ADD CONSTRAINT "quotes_match_id_matches_id_fk" FOREIGN KEY ("match_id") REFERENCES "public"."matches"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_booking_id_bookings_id_fk" FOREIGN KEY ("booking_id") REFERENCES "public"."bookings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_subject_id_users_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "accounts_user_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint
CREATE INDEX "users_phone_idx" ON "users" USING btree ("phone");--> statement-breakpoint
CREATE INDEX "verifications_identifier_idx" ON "verifications" USING btree ("identifier","expires_at");--> statement-breakpoint
CREATE INDEX "credentials_pro_idx" ON "credentials" USING btree ("pro_id");--> statement-breakpoint
CREATE INDEX "credentials_review_idx" ON "credentials" USING btree ("review_status");--> statement-breakpoint
CREATE INDEX "credentials_expiry_idx" ON "credentials" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "pro_availability_pro_idx" ON "pro_availability" USING btree ("pro_id","weekday");--> statement-breakpoint
CREATE INDEX "pro_categories_category_idx" ON "pro_categories" USING btree ("category_id");--> statement-breakpoint
CREATE INDEX "pro_media_pro_idx" ON "pro_media" USING btree ("pro_id","position");--> statement-breakpoint
CREATE INDEX "pro_profiles_location_gist" ON "pro_profiles" USING gist ("base_location");--> statement-breakpoint
CREATE INDEX "pro_profiles_deck_idx" ON "pro_profiles" USING btree ("verification_status","is_accepting_jobs") WHERE "pro_profiles"."verification_status" = 'verified' AND "pro_profiles"."is_accepting_jobs" = true;--> statement-breakpoint
CREATE INDEX "verification_sessions_pro_idx" ON "verification_sessions" USING btree ("pro_id");--> statement-breakpoint
CREATE INDEX "jobs_location_gist" ON "jobs" USING gist ("location");--> statement-breakpoint
CREATE INDEX "jobs_client_idx" ON "jobs" USING btree ("client_id","status");--> statement-breakpoint
CREATE INDEX "jobs_open_idx" ON "jobs" USING btree ("category_id") WHERE "jobs"."status" = 'open';--> statement-breakpoint
CREATE INDEX "matches_pro_idx" ON "matches" USING btree ("pro_id","last_message_at");--> statement-breakpoint
CREATE INDEX "matches_client_idx" ON "matches" USING btree ("client_id","last_message_at");--> statement-breakpoint
CREATE INDEX "matches_job_idx" ON "matches" USING btree ("job_id");--> statement-breakpoint
CREATE INDEX "requests_pending_idx" ON "requests" USING btree ("pro_id","expires_at") WHERE "requests"."status" = 'pending';--> statement-breakpoint
CREATE INDEX "requests_job_idx" ON "requests" USING btree ("job_id","status");--> statement-breakpoint
CREATE INDEX "swipes_job_idx" ON "swipes" USING btree ("job_id");--> statement-breakpoint
CREATE INDEX "messages_match_idx" ON "messages" USING btree ("match_id","created_at");--> statement-breakpoint
CREATE INDEX "messages_unread_idx" ON "messages" USING btree ("match_id","sender_id") WHERE "messages"."read_at" IS NULL;--> statement-breakpoint
CREATE INDEX "bookings_match_idx" ON "bookings" USING btree ("match_id");--> statement-breakpoint
CREATE INDEX "bookings_status_idx" ON "bookings" USING btree ("status","pro_completed_at");--> statement-breakpoint
CREATE INDEX "bookings_schedule_idx" ON "bookings" USING btree ("scheduled_start");--> statement-breakpoint
CREATE INDEX "payments_status_idx" ON "payments" USING btree ("status");--> statement-breakpoint
CREATE INDEX "payments_intent_idx" ON "payments" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "quotes_match_idx" ON "quotes" USING btree ("match_id","status");--> statement-breakpoint
CREATE INDEX "reviews_subject_idx" ON "reviews" USING btree ("subject_id","published_at");--> statement-breakpoint
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("entity","entity_id");--> statement-breakpoint
CREATE INDEX "audit_log_actor_idx" ON "audit_log" USING btree ("actor_id","created_at");