diff --git a/.env.example b/.env.example index dd76a4a..0f4d75a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ REDIS_URL=redis://localhost:6389 # generate with: openssl rand -base64 32 AUTH_SECRET= AUTH_URL=http://localhost:3000 +# Optional. Leave blank and phone OTP is the only route: the "Continue with +# Google" button still renders, and tells the user it is not set up. +# Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= @@ -48,3 +51,6 @@ NEXT_PUBLIC_CITY_NAME=Barcelona NEXT_PUBLIC_CITY_LAT=41.3874 NEXT_PUBLIC_CITY_LNG=2.1686 TWILIO_FROM_NUMBER= + +# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. +ALLOW_DEV_LOGIN=false diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 75e2553..33012f2 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -10,6 +10,9 @@ const config: NextConfig = { transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'], images: { remotePatterns: [ + // Seed data only — real pros upload to R2. The deck renders a plain , + // so these matter only where next/image is used. + { protocol: 'https', hostname: 'i.pravatar.cc' }, { protocol: 'https', hostname: 'picsum.photos' }, { protocol: 'https', hostname: '**.r2.dev' }, ], diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..6d7ff77 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,27 @@ +/* + * Kill-switch service worker. + * + * This app does not use a service worker. One was left registered on + * localhost:3000 by a DIFFERENT project — service workers are scoped to an + * origin, not a project, so any app later served on that port inherits it. It + * intercepted requests and served dead chunks, which surfaced as + * "Cannot read properties of undefined (reading 'call')" in RootLayout and + * survived deleting .next and restarting the dev server. + * + * The browser re-fetches /sw.js to check for updates; serving this makes the + * stale worker replace itself with one that immediately unregisters and drops + * every cache it holds. + */ +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + await self.registration.unregister(); + const clientList = await self.clients.matchAll({ type: 'window' }); + for (const client of clientList) client.navigate(client.url); + })(), + ); +}); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c66902d..7e2f389 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata, Viewport } from 'next'; import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google'; import { TRPCProvider } from '@/lib/trpc'; +import { ToastProvider } from '@/components/ui'; +import { PhoneFrame } from '@/components/chrome/phone-frame'; import '@/styles/globals.css'; /** @@ -52,7 +54,18 @@ export default function RootLayout({ children }: { children: React.ReactNode }) illustration that has to go full-bleed past it. §4 */} - {children} + + + {/* + The phone lives HERE, not in a page, so that every route renders + inside the screen. Putting it in one page meant sign-in, onboarding + and the job form all escaped the frame. + */} +
+ {children} +
+
+
); diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx index 3cddc18..1ae05af 100644 --- a/apps/web/src/app/onboarding/page.tsx +++ b/apps/web/src/app/onboarding/page.tsx @@ -28,7 +28,7 @@ export default async function OnboardingPage() { if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding'); return ( - +

What brings you here?

You can only pick once, so choose the one that fits. diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 45499a7..89f6135 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,4 @@ import { getApi } from '@/server/caller'; -import { PhoneFrame } from '@/components/chrome/phone-frame'; import { ShowcaseDeck } from './showcase-deck'; export const dynamic = 'force-dynamic'; @@ -18,13 +17,15 @@ export const dynamic = 'force-dynamic'; */ export default async function Home() { const api = await getApi(); - const { cards } = await api.deck.showcase(); + const [{ cards }, categories] = await Promise.all([ + api.deck.showcase(), + api.job.categories(), + ]); return ( -

- - - -
+ ({ id: c.id, name: c.name }))} + initialCards={cards} + /> ); } diff --git a/apps/web/src/app/pro/onboarding/wizard.tsx b/apps/web/src/app/pro/onboarding/wizard.tsx index 6e2a734..096e48c 100644 --- a/apps/web/src/app/pro/onboarding/wizard.tsx +++ b/apps/web/src/app/pro/onboarding/wizard.tsx @@ -67,7 +67,7 @@ export function OnboardingWizard({ const upsert = api.pro.upsertProfile.useMutation(); const addMedia = api.pro.addMedia.useMutation(); const addCredential = api.pro.addCredential.useMutation(); - const setEmailMutation = api.user.setEmail.useMutation(); + const setEmailMutation = api.user.requestEmailChange.useMutation(); const submit = api.pro.submitForReview.useMutation({ onSuccess: () => router.push('/pro'), onError: (e) => setError(e.message), @@ -276,12 +276,14 @@ export function OnboardingWizard({ await setEmailMutation.mutateAsync({ email }); setError(null); router.refresh(); + // The address is not live until the emailed link is opened, + // so do not let the wizard imply the step is finished. } catch (e) { setError((e as Error).message); } }} > - Save email + {setEmailMutation.isSuccess ? "Check your inbox" : "Send confirmation"} )} diff --git a/apps/web/src/app/profile-panel.tsx b/apps/web/src/app/profile-panel.tsx new file mode 100644 index 0000000..8895475 --- /dev/null +++ b/apps/web/src/app/profile-panel.tsx @@ -0,0 +1,257 @@ +'use client'; + +import Link from 'next/link'; +import { Hammer } from 'lucide-react'; +import { Card } from '@/components/deck'; +import { SignedOut } from '@/components/chrome/signed-out'; +import { SkillsGroup } from '@/components/profile/skills-group'; +import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui'; +import { api, type RouterOutputs } from '@/lib/trpc'; + +/** + * The Profile tab. + * + * Two different screens behind one tab, because the word means two different + * things here: for a pro the profile IS the product — the card clients swipe — + * while a client has almost nothing to show and is better served by a route into + * pro onboarding, since a cold deck is what actually kills this marketplace. + */ +export function ProfilePanel() { + const me = api.user.me.useQuery(undefined, { retry: false }); + + if (me.isLoading) { + return ( + +
+ + ); + } + if (me.error || !me.data) { + return ( + + ); + } + + return me.data.role === 'pro' ? : ; +} + +function Shell({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +type Me = RouterOutputs['user']['me']; + +/* ─────────────────────────────── pro ─────────────────────────────── */ + +/** What each verification status means commercially — this is the row that decides + * whether the pro exists to customers at all. */ +const STATUS: Record< + string, + { label: string; body: string; tone: 'success' | 'warning' | 'error' } +> = { + draft: { + label: 'Not submitted', + body: 'Customers cannot see you yet. Finish your profile to go live.', + tone: 'warning', + }, + pending: { + label: 'In review', + body: 'We check every ID, licence and insurance certificate. Usually about a day.', + tone: 'warning', + }, + verified: { + label: 'Live on the deck', + body: 'Customers in your area can see and swipe your card.', + tone: 'success', + }, + rejected: { + label: 'Not approved', + body: 'Something did not check out. Contact support and we will tell you what to fix.', + tone: 'error', + }, + suspended: { + label: 'Suspended', + body: 'Your account is on hold. Contact support — this cannot be lifted from here.', + tone: 'error', + }, +}; + +function ProProfile() { + const utils = api.useUtils(); + const preview = api.pro.previewCard.useQuery(); + const profile = api.pro.me.useQuery(); + + const setAccepting = api.pro.setAcceptingJobs.useMutation({ + onSettled: () => { + void utils.pro.me.invalidate(); + void utils.pro.previewCard.invalidate(); + }, + }); + + if (preview.isLoading || profile.isLoading) { + return ( + +
+ + ); + } + + const card = preview.data; + const p = profile.data; + + if (!card || !p) { + return ( + +

Your profile

+

You have not set up your pro profile yet.

+ + Set up my profile + +
+ ); + } + + const status = STATUS[p.verificationStatus] ?? STATUS.draft!; + const isVerified = p.verificationStatus === 'verified'; + const has = (kind: string) => p.credentials.some((c) => c.kind === kind); + + return ( + +

Your card

+

Exactly what customers see when they swipe.

+ + {/* + The real , not a lookalike — a copy would drift the moment either + side changed, and the whole point is that a pro can trust this preview. + No onDecide, so it renders static and non-draggable. + */} +
+ +
+

+ The distance shown is an example — customers see how far you are from their own job. +

+ + + {status.body} + + +
+ + setAccepting.mutate({ accepting: v })} + /> + + + + + + + + + + + + + + + + + + + 0 + ? `${Number(p.ratingAvg).toFixed(1)} ★ (${p.ratingCount})` + : 'No jobs yet' + } + /> + + +
+
+ ); +} + +/* ───────────────────────────── client ────────────────────────────── */ + +function ClientProfile({ me }: { me: Me }) { + const jobs = api.job.mine.useQuery(); + + return ( + +

Your profile

+ + + + + + + + {/* + The most valuable thing on an otherwise empty screen. A marketplace with + no pros has no product, so recruiting supply beats decorating a client + profile that has nothing on it. + */} +
+ + + For tradespeople + +

Work with us

+

+ Get sent local jobs that match your trade. We check every pro’s ID, licence and + insurance, so customers arrive ready to book. +

+ {/* + user.setRole refuses once a job has been posted, so this must not read + as a switch that flips this account over. + */} +

+ Working as a pro needs its own account — you keep this one for hiring. +

+ + Join as a pro + +
+
+ ); +} diff --git a/apps/web/src/app/settings-panel.tsx b/apps/web/src/app/settings-panel.tsx new file mode 100644 index 0000000..091e0dd --- /dev/null +++ b/apps/web/src/app/settings-panel.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useState } from 'react'; +import { SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui'; +import { api, type RouterOutputs } from '@/lib/trpc'; +import { SignedOut } from '@/components/chrome/signed-out'; +import { LocationGroup } from '@/components/settings/location-group'; +import { authClient } from '@/lib/auth-client'; + +/** + * The Settings tab. + * + * Signed-in only. An anonymous visitor gets a sign-in prompt rather than a + * disabled tab, so the bar does not look dead on first open. + */ +export function SettingsPanel() { + const me = api.user.me.useQuery(undefined, { retry: false }); + + if (me.isLoading) { + return
; + } + if (me.error || !me.data) { + return ( + + ); + } + + return ; +} + +function PanelShell({ children }: { children: React.ReactNode }) { + return ( +
+

Settings

+ {children} +
+ ); +} + +type Me = RouterOutputs['user']['me']; + +function SignedIn({ me }: { me: Me }) { + const utils = api.useUtils(); + const prefs = api.notification.get.useQuery(); + const updatePrefs = api.notification.update.useMutation({ + onMutate: async (next) => { + // Optimistic: a switch that lags behind the thumb feels broken. + await utils.notification.get.cancel(); + const previous = utils.notification.get.getData(); + if (previous) utils.notification.get.setData(undefined, { ...previous, ...next }); + return { previous }; + }, + onError: (_e, _next, context) => { + if (context?.previous) utils.notification.get.setData(undefined, context.previous); + }, + onSettled: () => void utils.notification.get.invalidate(), + }); + + const sessions = api.user.sessions.useQuery(); + const requestDeletion = api.user.requestDeletion.useMutation(); + const [deletionAsked, setDeletionAsked] = useState(false); + + const p = prefs.data; + const isPro = me.role === 'pro'; + + return ( + + + + + {/* + Read-only by necessity, not by choice: better-auth's phoneNumber plugin + rejects any update carrying a phone, and the number is the login + credential and the unique key. + */} + + + + + + + + updatePrefs.mutate({ smsNewRequest: v })} + /> + updatePrefs.mutate({ smsBookingReminder: v })} + /> + updatePrefs.mutate({ emailReceipts: v })} + /> + updatePrefs.mutate({ smsMarketing: v })} + /> + + + {isPro && ( + + {}} /> + + )} + + + + {sessions.data?.some((s) => s.isImpersonated) && ( + + )} + + + + {}} /> + {}} /> + { + const data = await utils.user.exportData.fetch(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'linkder-data.json'; + a.click(); + URL.revokeObjectURL(url); + }} + /> + { + requestDeletion.mutate({}, { onSuccess: () => setDeletionAsked(true) }); + }} + /> + + + + + ); +} diff --git a/apps/web/src/app/showcase-deck.tsx b/apps/web/src/app/showcase-deck.tsx index 7326a69..0977ed5 100644 --- a/apps/web/src/app/showcase-deck.tsx +++ b/apps/web/src/app/showcase-deck.tsx @@ -2,42 +2,115 @@ import { useState } from 'react'; import Link from 'next/link'; +import { X } from 'lucide-react'; import type { DeckCard } from '@linkder/db'; import { Deck } from '@/components/deck'; -import { buttonClasses } from '@/components/ui'; +import { Chip } from '@/components/ui'; +import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs'; +import { SettingsPanel } from './settings-panel'; +import { ProfilePanel } from './profile-panel'; +import { api } from '@/lib/trpc'; + +export interface Category { + id: string; + name: string; +} /** - * The live demo deck inside the phone on the entry screen. + * The entry screen: a live deck of real verified pros, and a strip of trades + * above it. * - * Real pros, real ranking, real drag physics — but `onDecide` deliberately - * writes NOTHING. A visitor with no session swiping right must not send a job - * to a real tradesperson; the card simply leaves. The funnel starts when they - * tap "Post a job", which is where a real deck (deck.list / deck.swipe, both - * authenticated) takes over. + * The trade strip exists because a deck of every trade is useless to someone + * with a leaking sink — a dating app can show you anyone, a trades app cannot. + * Until a trade is picked the deck shows everyone, so the screen is never empty + * and the first swipe costs no taps. + * + * `onDecide` deliberately writes NOTHING. A visitor with no session swiping + * right must not send a job to a real tradesperson; the card simply leaves. The + * funnel starts at "Post a job", where the authenticated deck (deck.list / + * deck.swipe) takes over. */ -export function ShowcaseDeck({ cards }: { cards: DeckCard[] }) { - const [seen, setSeen] = useState(0); - const done = seen >= cards.length; +export function ShowcaseDeck({ + categories, + initialCards, +}: { + categories: Category[]; + initialCards: DeckCard[]; +}) { + const [categoryId, setCategoryId] = useState(null); + const [tab, setTab] = useState('swipe'); + + // Filtering happens server-side: a page is 20 cards across 8 trades, so + // filtering an already-fetched page would leave two or three per trade. + const { data, isFetching } = api.deck.showcase.useQuery( + categoryId ? { categoryId } : {}, + { initialData: categoryId ? undefined : { cards: initialCards }, staleTime: 60_000 }, + ); + + const cards = data?.cards ?? []; + const selected = categories.find((c) => c.id === categoryId) ?? null; return ( -
-
-

Verified pros near you

- {!done && cards.length > 0 && ( -

{cards.length - seen} left

+
+ {tab === 'settings' ? ( + + ) : tab === 'profile' ? ( + + ) : tab !== 'swipe' ? ( +
+ Coming soon. +
+ ) : ( + <> + {/* Trade strip. Above the card, never over the photo — so it cannot steal + the drag gesture and never has to stay legible on a bright image. */} +
+ {selected ? ( + setCategoryId(null)}> + {selected.name} + + Show all trades + + ) : ( +
+ {categories.map((c) => ( + setCategoryId(c.id)}> + {c.name} + + ))} +
)} -
- -
- setSeen((n) => n + 1)} />
- - Post a job - + {/* The deck owns everything left over. min-h-0 so it can actually shrink. */} +
+ {isFetching && cards.length === 0 ? ( +
+ ) : ( + {}} + /> + )} +
+ +

+ Seen someone?{' '} + + Post a job + +

+ + )} + +
); } diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx index efc9879..e4cdfc7 100644 --- a/apps/web/src/app/sign-in/page.tsx +++ b/apps/web/src/app/sign-in/page.tsx @@ -6,7 +6,7 @@ export const metadata = { title: 'Sign in' }; export default function SignInPage() { return ( - +

Linkder

Sign in

diff --git a/apps/web/src/app/sign-in/sign-in-form.tsx b/apps/web/src/app/sign-in/sign-in-form.tsx index 64ee757..25c80c1 100644 --- a/apps/web/src/app/sign-in/sign-in-form.tsx +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { authClient } from '@/lib/auth-client'; import { Button, Field, FormError, Input } from '@/components/ui'; +import { GoogleButton } from '@/components/auth/google-button'; type Step = 'phone' | 'code'; @@ -121,15 +122,7 @@ export function SignInForm() {

- +

Signing in with Google creates a separate account from a phone sign-in. If you have used diff --git a/apps/web/src/components/auth/google-button.tsx b/apps/web/src/components/auth/google-button.tsx new file mode 100644 index 0000000..7ef13c8 --- /dev/null +++ b/apps/web/src/components/auth/google-button.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { authClient } from '@/lib/auth-client'; +import { Button, useToast } from '@/components/ui'; + +/** + * "Continue with Google" — the one social route, offered wherever we ask + * someone to sign in. + * + * The button renders whether or not the server has Google credentials. Hiding + * it when the keys are missing would mean the sign-in screen quietly changes + * shape between environments, so a layout that works on a developer's machine + * is one nobody has actually seen in production — and the first person to + * notice would be a user. It is always here; when the server cannot honour it, + * the click says so out loud. + * + * `lib/auth.ts` registers the provider only when both AUTH_GOOGLE_ID and + * AUTH_GOOGLE_SECRET are set, so the unconfigured case comes back as a clean + * 404 PROVIDER_NOT_FOUND rather than a 500 from deep inside the OAuth builder. + * That is what makes "not set up" distinguishable here from "Google is down". + */ +export function GoogleButton({ + callbackURL, + size = 'lg', + block = true, + label = 'Continue with Google', +}: { + /** Where to land after Google sends the browser back. */ + callbackURL: string; + size?: 'sm' | 'md' | 'lg'; + block?: boolean; + label?: string; +}) { + const toast = useToast(); + const [busy, setBusy] = useState(false); + + async function start() { + setBusy(true); + const { error } = await authClient.signIn.social({ provider: 'google', callbackURL }); + // On success better-auth's redirect plugin has already sent the browser to + // Google, so this line is only ever reached on failure — but leave `busy` + // set in the success case rather than flicking the spinner off under a + // navigation that is already in flight. + if (!error) return; + setBusy(false); + + if (error.status === 404 || error.code === 'PROVIDER_NOT_FOUND') { + toast('Sign in with your mobile number instead — it takes about the same time.', { + tone: 'warning', + title: 'Google sign-in is not set up yet', + }); + return; + } + + toast(error.message ?? 'Google did not respond. Try again, or use your mobile number.', { + tone: 'error', + title: 'Could not continue with Google', + }); + } + + return ( + + ); +} + +/** + * Google's mark, per their branding terms: the four-colour G, never recoloured + * and never swapped for a monochrome icon-font glyph. + */ +function GoogleMark() { + return ( + + + + + + + ); +} diff --git a/apps/web/src/components/chrome/app-shell.tsx b/apps/web/src/components/chrome/app-shell.tsx index 6652280..d762ded 100644 --- a/apps/web/src/components/chrome/app-shell.tsx +++ b/apps/web/src/components/chrome/app-shell.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { BackLink } from './back-link'; /** * DESIGN.md §4. The one screen wrapper. @@ -22,7 +23,7 @@ export function AppShell({ return (

+export function BareShell({ + back = false, + children, +}: { + back?: boolean; + children: React.ReactNode; +}) { + return ( +
+ {back && ( +
+ +
+ )} {children} -
; +
+ ); } /** @@ -50,7 +68,7 @@ export function BareShell({ children }: { children: React.ReactNode }) { */ export function DeckShell({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); diff --git a/apps/web/src/components/chrome/back-link.tsx b/apps/web/src/components/chrome/back-link.tsx new file mode 100644 index 0000000..5ce9392 --- /dev/null +++ b/apps/web/src/components/chrome/back-link.tsx @@ -0,0 +1,41 @@ +import Link from 'next/link'; +import { ChevronLeft } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * The way out of a dead end. + * + * DESIGN.md §6.6 hangs a back chevron off the app bar, but there is no app bar + * here — so on the bare screens it sits in the top-left corner of the screen + * instead, clear of the vertically centred content and of the Dynamic Island. + * + * A real , not `router.back()`: someone who landed on sign-in from a + * bookmark or an expired-session redirect has no history to go back to, and a + * button that sometimes does nothing is worse than no button. + */ +export function BackLink({ + href = '/', + label = 'Home', + className, +}: { + href?: string; + label?: string; + className?: string; +}) { + return ( + + + {label} + + ); +} diff --git a/apps/web/src/components/chrome/phone-frame.tsx b/apps/web/src/components/chrome/phone-frame.tsx index 007c2d7..b1b7b93 100644 --- a/apps/web/src/components/chrome/phone-frame.tsx +++ b/apps/web/src/components/chrome/phone-frame.tsx @@ -22,10 +22,14 @@ export function PhoneFrame({ return (
void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/chrome/signed-out.tsx b/apps/web/src/components/chrome/signed-out.tsx new file mode 100644 index 0000000..b91ff74 --- /dev/null +++ b/apps/web/src/components/chrome/signed-out.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { LogIn } from 'lucide-react'; +import { buttonClasses } from '@/components/ui'; + +/** + * What a signed-in-only tab shows to an anonymous visitor. + * + * The tab stays tappable rather than being greyed out — a bar of dead icons on + * first open reads as a broken app, whereas this explains what is behind it. + */ +export function SignedOut({ title, body }: { title: string; body: string }) { + return ( +
+ +
+

{title}

+

{body}

+
+ + Continue with phone + +
+ ); +} diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx index 3c95df3..ec35189 100644 --- a/apps/web/src/components/deck.tsx +++ b/apps/web/src/components/deck.tsx @@ -66,13 +66,20 @@ export function Deck({ cards, onDecide }: DeckProps) { ); } -function Card({ +/** + * One card. + * + * Exported so the profile screen can render a pro their OWN card, byte for byte + * what a client sees. A lookalike would drift the moment either side changed. + * Pass no `onDecide` to get a static, non-draggable card. + */ +export function Card({ card, - depth, + depth = 0, onDecide, }: { card: DeckCard; - depth: number; + depth?: number; onDecide?: (proId: string, direction: 'left' | 'right') => void; }) { const x = useMotionValue(0); @@ -86,7 +93,7 @@ function Card({ SEND JOB @@ -134,7 +141,13 @@ function Card({
-

{card.name}

+ {/* + text-white must be on the h2 itself, not inherited from the wrapper: + globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer + base, and that rule beats the parent's colour. Without this the name + renders ink-950 navy on a dark photo and is unreadable. + */} +

{card.name}

{card.ratingCount > 0 ? ( @@ -187,10 +200,10 @@ function ActionButton({ aria-label={label} title={label} className={cn( - 'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg', + 'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg', 'transition hover:scale-105 active:scale-95', isHire - ? 'border-[var(--color-go-500)] text-[var(--color-go-500)]' + ? 'border-[var(--color-go-600)] text-[var(--color-go-600)]' : 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]', )} > diff --git a/apps/web/src/components/profile/skills-group.tsx b/apps/web/src/components/profile/skills-group.tsx new file mode 100644 index 0000000..5259fa1 --- /dev/null +++ b/apps/web/src/components/profile/skills-group.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Plus, X } from 'lucide-react'; +import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared'; +import { api } from '@/lib/trpc'; +import { Button, FormError, Input, SettingsGroup } from '@/components/ui'; + +/** + * What this pro is actually good at, in their own words. + * + * Free text on purpose. The trade list is a closed set because matching and + * licence checks run on it; this is the line underneath that separates two + * plumbers who match the same job — "underfloor heating", "listed buildings", + * "emergency callouts". Nothing matches on it, so nothing here can distort the + * deck; it only has to read well. + */ +export function SkillsGroup({ skills }: { skills: string[] }) { + const utils = api.useUtils(); + const [list, setList] = useState(skills); + const [draft, setDraft] = useState(''); + const [error, setError] = useState(null); + const [dirty, setDirty] = useState(false); + + // Adopt the server's list until the pro starts editing — after that the + // in-progress edit wins, or a refetch would wipe it mid-sentence. + useEffect(() => { + if (!dirty) setList(skills); + }, [skills, dirty]); + + const save = api.pro.updateSkills.useMutation({ + onSuccess: ({ skills: next }) => { + setList(next); + setDirty(false); + void utils.pro.me.invalidate(); + }, + onError: (e) => setError(e.message), + }); + + function add() { + const value = draft.trim(); + if (!value) return; + if (value.length < 2) return setError('That is too short to mean anything.'); + if (list.some((s) => s.toLocaleLowerCase() === value.toLocaleLowerCase())) { + setDraft(''); + return setError('That one is already on the list.'); + } + if (list.length >= MAX_SKILLS) { + return setError(`${MAX_SKILLS} is the most a customer will read.`); + } + + setList([...list, value]); + setDraft(''); + setError(null); + setDirty(true); + } + + function remove(skill: string) { + setList(list.filter((s) => s !== skill)); + setError(null); + setDirty(true); + } + + const full = list.length >= MAX_SKILLS; + + return ( + +
+ {list.length > 0 ? ( +
    + {list.map((skill) => ( +
  • + + {skill} + + +
  • + ))} +
+ ) : ( +

+ Nothing yet. Two or three specific ones beat a long generic list. +

+ )} + + {/* + Not a
: this sits inside the profile screen, and a nested form + would submit the wrong thing the day that screen grows one. + */} +
+ { + setDraft(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + add(); + }} + maxLength={MAX_SKILL_LENGTH} + placeholder="Boiler repair" + aria-label="Add a skill" + disabled={full} + /> + +
+ +

+ {list.length}/{MAX_SKILLS} +

+ + {error && {error}} + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/location-group.tsx b/apps/web/src/components/settings/location-group.tsx new file mode 100644 index 0000000..5b9aab8 --- /dev/null +++ b/apps/web/src/components/settings/location-group.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { LocateFixed } from 'lucide-react'; +import { + DEFAULT_SERVICE_RADIUS_M, + MAX_SERVICE_RADIUS_M, + MIN_SERVICE_RADIUS_M, +} from '@linkder/shared'; +import { api } from '@/lib/trpc'; +import { Button, FormError, Input, SettingsGroup } from '@/components/ui'; + +const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre'; + +/** + * Where you are, and how far you will go. + * + * One group for both sides of the market, because it is one question — only the + * words change. What it writes does not: a pro's answer is their service area + * and lands on the pro profile the deck matches against, a customer's is a + * search preference and lands on the user. The server picks; this only renders. + */ +export function LocationGroup({ isPro }: { isPro: boolean }) { + const utils = api.useUtils(); + const saved = api.user.location.useQuery(); + + const [addressText, setAddressText] = useState(''); + const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000); + const [pin, setPin] = useState<{ lat: number; lng: number } | null>(null); + const [error, setError] = useState(null); + const [savedJustNow, setSavedJustNow] = useState(false); + // Seeding the controls from the query would otherwise overwrite what someone + // is halfway through typing, every time the query refetches. + const [dirty, setDirty] = useState(false); + + useEffect(() => { + if (!saved.data || dirty) return; + setAddressText(saved.data.addressText ?? ''); + setRadiusKm(Math.round(saved.data.radiusM / 1000)); + setPin(saved.data.location); + }, [saved.data, dirty]); + + const update = api.user.updateLocation.useMutation({ + onSuccess: () => { + setDirty(false); + setSavedJustNow(true); + void utils.user.location.invalidate(); + // A customer's deck is filtered by this, so it cannot keep serving the + // results of the old radius. + void utils.deck.invalidate(); + }, + onError: (e) => setError(e.message), + }); + + function edit(set: (value: T) => void) { + return (value: T) => { + set(value); + setDirty(true); + setSavedJustNow(false); + setError(null); + }; + } + + const title = isPro ? 'Where you work' : 'Where you are'; + + if (saved.isLoading) { + return ( + +
+ + ); + } + + // A pro who has not finished onboarding has no service area yet, and inventing + // one here would be a second source of truth for the wizard to fight. + if (saved.data?.needsProfile) { + return ( + +
+ Your working area is part of your pro profile. +
+
+ ); + } + + return ( + +
+
+ +

+ {pin + ? `Pinned to ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}. ` + : `No pin yet — we measure from ${CITY_NAME}. `} + {isPro + ? 'Matching uses the pin, never the text.' + : 'Matching uses the pin; your address is only shared once you book.'} +

+ +
+ + + + {error && {error}} + + +
+
+ ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index cff84a3..991aedb 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -4,3 +4,5 @@ export { Card, EmptyState, Stat } from './card'; export { Chip, OptionCard, Tag } from './chip'; export { Field, FieldNote, FieldSet, Input, Textarea } from './field'; export { ScreenIntro, Section, StickyAction } from './page'; +export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row'; +export { ToastProvider, useToast, type ToastTone } from './toast'; diff --git a/apps/web/src/components/ui/settings-row.tsx b/apps/web/src/components/ui/settings-row.tsx new file mode 100644 index 0000000..cbe96ab --- /dev/null +++ b/apps/web/src/components/ui/settings-row.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** A titled group of rows. Settings is a list of lists. */ +export function SettingsGroup({ + title, + note, + children, +}: { + title: string; + note?: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+
+ {children} +
+ {note &&

{note}

} +
+ ); +} + +/** A read-only or navigational row. */ +export function SettingsRow({ + label, + value, + hint, + onClick, + danger, + disabled, +}: { + label: string; + value?: React.ReactNode; + hint?: string; + onClick?: () => void; + danger?: boolean; + disabled?: boolean; +}) { + const interactive = Boolean(onClick) && !disabled; + const Tag = interactive ? 'button' : 'div'; + + return ( + + + + {label} + + {hint && {hint}} + + {value !== undefined && ( + {value} + )} + {interactive && } + + ); +} + +/** + * A row carrying a switch. The label is the control's accessible name, so the + * whole row is one tap target rather than a label and a separate 20px switch. + */ +export function SettingsToggle({ + label, + hint, + checked, + onChange, + disabled, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (next: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..5f5283f --- /dev/null +++ b/apps/web/src/components/ui/toast.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { createContext, useCallback, useContext, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * Transient messages. + * + * Deliberately NOT a second visual language: a toast is §6.5's banner on a + * shadow, so a tone means the same thing wherever it appears. What it adds is + * placement — bottom of the viewport rather than in the flow — for things the + * user needs to be told but that do not belong to any one field. + * + * Use `` for "this input is wrong" and a `` for state a + * screen is permanently in. A toast is for the third case: an outcome that has + * no home on the screen, like a provider the server is not configured for. + */ +export type ToastTone = 'info' | 'success' | 'warning' | 'error'; + +export interface ToastOptions { + tone?: ToastTone; + title?: string; + /** Milliseconds on screen. `null` keeps it up until dismissed. */ + duration?: number | null; +} + +interface Toast extends ToastOptions { + id: number; + message: string; +} + +type ToastFn = (message: string, options?: ToastOptions) => void; + +const ToastContext = createContext(null); + +/** + * Throws rather than no-oping when the provider is missing. A toast that + * silently does nothing is worse than no toast — it looks like the click did + * not register, and the bug only shows up in the case nobody tests. + */ +export function useToast(): ToastFn { + const toast = useContext(ToastContext); + if (!toast) throw new Error('useToast must be used inside '); + return toast; +} + +const DEFAULT_DURATION = 6000; + +const TONE_SURFACE: Record = { + info: 'border-brand-200 bg-brand-100', + success: 'border-go-100 bg-go-50', + warning: 'border-sun-100 bg-sun-50', + error: 'border-stop-100 bg-stop-50', +}; + +const TONE_ICON = { + info: Info, + success: CheckCircle2, + warning: AlertTriangle, + error: XCircle, +} as const; + +const TONE_ICON_COLOR: Record = { + info: 'text-brand-500', + success: 'text-go-600', + warning: 'text-sun-500', + error: 'text-stop-500', +}; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(0); + // Cleared on dismiss so a hand-dismissed toast does not leave a timer that + // later removes whatever toast happens to be in its place. + const timers = useRef(new Map>()); + + const dismiss = useCallback((id: number) => { + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + setToasts((current) => current.filter((t) => t.id !== id)); + }, []); + + const toast = useCallback( + (message, options = {}) => { + const id = nextId.current++; + const duration = options.duration === undefined ? DEFAULT_DURATION : options.duration; + // Three is the point where the stack starts covering the thing the user + // was looking at; drop the oldest rather than growing upward forever. + setToasts((current) => [...current, { ...options, id, message }].slice(-3)); + if (duration !== null) { + timers.current.set(id, setTimeout(() => dismiss(id), duration)); + } + }, + [dismiss], + ); + + return ( + + {children} + {/* + The live region is mounted always, empty or not: a region inserted at + the same moment as its content is not reliably announced. + */} +
+ + {toasts.map((t) => ( + dismiss(t.id)} /> + ))} + +
+
+ ); +} + +function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) { + const tone = toast.tone ?? 'info'; + const Icon = TONE_ICON[tone]; + + return ( + + +
+ {toast.title &&

{toast.title}

} +

{toast.message}

+
+ +
+ ); +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index a746d59..f5c95d1 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -5,6 +5,7 @@ import { nextCookies } from 'better-auth/next-js'; import { db, schema } from '@linkder/db'; import { isE164 } from '@linkder/shared'; import { sendVerificationSms } from '@/server/sms'; +import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login'; /** * Authentication. @@ -53,6 +54,16 @@ const appUrl = })() : 'http://localhost:3000'); +/** + * Google OAuth is optional. A developer clone with no Google project, and every + * preview deploy, should still boot and still sign people in by phone — so a + * missing key is a fact about the environment here, not an error like a missing + * AUTH_SECRET. What it must not do is silently half-register the provider; see + * `socialProviders` below. + */ +const googleId = process.env.AUTH_GOOGLE_ID; +const googleSecret = process.env.AUTH_GOOGLE_SECRET; + export const auth = betterAuth({ // Passing `schema` explicitly (rather than letting the adapter read // db._.fullSchema) keeps it from forcing our lazy db Proxy open at module @@ -128,16 +139,32 @@ export const auth = betterAuth({ emailAndPassword: { enabled: false }, - socialProviders: { - google: { - clientId: process.env.AUTH_GOOGLE_ID ?? '', - clientSecret: process.env.AUTH_GOOGLE_SECRET ?? '', - }, - }, + /** + * Google is registered only when it can actually work. + * + * With empty strings here better-auth still registers the provider, and + * /sign-in/social gets as far as the OAuth URL builder before throwing — + * a 500 that says nothing, on an environment that is merely unconfigured + * rather than broken. Omitting the provider instead makes the same click + * return 404 PROVIDER_NOT_FOUND, which can tell apart from a + * real failure and turn into "not set up yet" rather than "try again". + * + * The button itself is NOT conditional. See components/auth/google-button. + */ + socialProviders: googleId && googleSecret + ? { google: { clientId: googleId, clientSecret: googleSecret } } + : {}, plugins: [ phoneNumber({ sendOTP: async ({ phoneNumber: to, code }) => { + // The fixed dev account: overwrite the random code with the known one + // and send nothing. Hard-gated on NODE_ENV plus ALLOW_DEV_LOGIN — see + // @/server/dev-login. + if (isDevLoginPhone(to)) { + await pinDevLoginCode(to); + return; + } await sendVerificationSms(to, code); }, otpLength: 6, diff --git a/apps/web/src/server/dev-login.ts b/apps/web/src/server/dev-login.ts new file mode 100644 index 0000000..4a86c7e --- /dev/null +++ b/apps/web/src/server/dev-login.ts @@ -0,0 +1,51 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '@linkder/db'; + +/** + * A fixed test account for local development. + * + * Signing in normally needs a real handset to receive a real SMS, which makes + * the whole app untestable without a phone in your hand and Twilio credits. This + * pins one number to one known code so `pnpm dev` is usable. + * + * THREE independent guards, because a login bypass reaching production is the + * worst bug this codebase could ship: + * + * 1. NODE_ENV must not be 'production'. + * 2. ALLOW_DEV_LOGIN must be explicitly 'true' — being in dev is not enough. + * 3. The phone number must match exactly. + * + * Any one of them failing falls straight back to the real OTP path. + */ +const DEV_PHONE = '+34600000000'; +const DEV_CODE = '000000'; + +export function isDevLoginEnabled(): boolean { + return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true'; +} + +export function isDevLoginPhone(phone: string): boolean { + return isDevLoginEnabled() && phone === DEV_PHONE; +} + +/** + * Replace the freshly-generated random code with the fixed one. + * + * better-auth writes the verification row (identifier = the phone number, + * value = ":") and only then calls sendOTP, so by the time this + * runs there is a row to overwrite. Rewriting the value rather than intercepting + * the comparison means the real verify path still runs in full — same expiry, + * same attempt cap, same single-use consumption. + */ +export async function pinDevLoginCode(phone: string): Promise { + if (!isDevLoginPhone(phone)) return; + + await db + .update(schema.verifications) + .set({ value: `${DEV_CODE}:0` }) + .where(eq(schema.verifications.identifier, phone)); + + console.info(`\n [dev login] ${DEV_PHONE} → code ${DEV_CODE}\n`); +} + +export const DEV_LOGIN = { phone: DEV_PHONE, code: DEV_CODE } as const; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 31b109a..1ea5f8b 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -3,6 +3,7 @@ import { deckRouter } from './routers/deck'; import { jobRouter } from './routers/job'; import { proRouter } from './routers/pro'; import { uploadRouter } from './routers/upload'; +import { notificationRouter } from './routers/notification'; import { userRouter } from './routers/user'; /** @@ -15,6 +16,7 @@ export const appRouter = router({ pro: proRouter, upload: uploadRouter, user: userRouter, + notification: notificationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/deck.ts b/packages/api/src/routers/deck.ts index ab637a5..8272923 100644 --- a/packages/api/src/routers/deck.ts +++ b/packages/api/src/routers/deck.ts @@ -42,7 +42,15 @@ export const deckRouter = router({ * stops. Nobody is contacted until the visitor posts an actual job. */ showcase: publicProcedure - .input(z.object({ limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional() }).optional()) + .input( + z + .object({ + /** Narrow to one trade. Omitted shows every trade. */ + categoryId: z.string().uuid().optional(), + limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional(), + }) + .optional(), + ) .query(async ({ ctx, input }) => { const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT); const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG); @@ -54,7 +62,26 @@ export const deckRouter = router({ message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.', }); } - const cards = await getShowcaseDeck(ctx.db, { lat, lng, limit: input?.limit }); + + // Someone who has told us where they are gets their own neighbourhood + // rather than the city centre, and only pros inside the range they set. + // An anonymous visitor still gets the city — this stays a public query. + const me = ctx.session + ? await ctx.db.query.users.findFirst({ + where: eq(schema.users.id, ctx.session.userId), + columns: { location: true, searchRadiusM: true }, + }) + : undefined; + + const cards = await getShowcaseDeck(ctx.db, { + lat: me?.location?.lat ?? lat, + lng: me?.location?.lng ?? lng, + // Their limit applies only where we know their pin: measuring "5 km from + // me" from the city centre would be a different question entirely. + maxDistanceM: me?.location ? me.searchRadiusM : undefined, + categoryId: input?.categoryId, + limit: input?.limit, + }); return { cards }; }), diff --git a/packages/api/src/routers/notification.ts b/packages/api/src/routers/notification.ts new file mode 100644 index 0000000..3d1753f --- /dev/null +++ b/packages/api/src/routers/notification.ts @@ -0,0 +1,67 @@ +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { schema } from '@linkder/db'; +import { protectedProcedure, router } from '../trpc'; + +/** + * The preference set, mirrored from the table's own defaults. + * + * A user with no row gets these rather than a 404, so the settings screen never + * has to distinguish "never saved" from "saved the defaults". + */ +const DEFAULTS = { + smsNewRequest: true, + smsBookingReminder: true, + smsMarketing: false, + emailReceipts: true, + emailMarketing: false, + pushMessages: true, + pushRequests: true, +} as const; + +const preferencesSchema = z.object({ + smsNewRequest: z.boolean(), + smsBookingReminder: z.boolean(), + smsMarketing: z.boolean(), + emailReceipts: z.boolean(), + emailMarketing: z.boolean(), + pushMessages: z.boolean(), + pushRequests: z.boolean(), +}); + +export const notificationRouter = router({ + get: protectedProcedure.query(async ({ ctx }) => { + const row = await ctx.db.query.notificationPreferences.findFirst({ + where: eq(schema.notificationPreferences.userId, ctx.session.userId), + }); + if (!row) return { ...DEFAULTS }; + + const { userId: _userId, updatedAt: _updatedAt, ...prefs } = row; + return prefs; + }), + + /** + * Upsert. Always scoped to the caller — the userId comes from the session and + * is never accepted from the payload. + */ + update: protectedProcedure + .input(preferencesSchema.partial()) + .mutation(async ({ ctx, input }) => { + const values = { ...DEFAULTS, ...input, userId: ctx.session.userId, updatedAt: new Date() }; + + const [saved] = await ctx.db + .insert(schema.notificationPreferences) + .values(values) + // Only the keys the caller actually sent are overwritten, so a partial + // update cannot silently reset the preferences it did not mention. + .onConflictDoUpdate({ + target: schema.notificationPreferences.userId, + set: { ...input, updatedAt: new Date() }, + }) + .returning(); + + if (!saved) return { ...DEFAULTS }; + const { userId: _userId, updatedAt: _updatedAt, ...prefs } = saved; + return prefs; + }), +}); diff --git a/packages/api/src/routers/pro.ts b/packages/api/src/routers/pro.ts index d5a4ab2..52f51aa 100644 --- a/packages/api/src/routers/pro.ts +++ b/packages/api/src/routers/pro.ts @@ -2,7 +2,12 @@ import { TRPCError } from '@trpc/server'; import { and, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { schema } from '@linkder/db'; -import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared'; +import { + assertTransition, + credentialSchema, + proProfileSchema, + updateSkillsSchema, +} from '@linkder/shared'; import { protectedProcedure, proProcedure, router } from '../trpc'; /** @@ -145,6 +150,142 @@ export const proRouter = router({ return { saved: true, requiresReReview: sendBackForReview }; }), + + /** + * Replace the skill list. + * + * Whole list rather than add/remove: the editor holds the full set anyway, and + * a delta API would need its own ordering and conflict rules for a field this + * small. + * + * Unlike trades, location and radius, this does NOT send a verified pro back + * for review. Skills are the pro's own description of their work, in the same + * class as the headline and bio — what verification actually checks is the + * licence behind a trade, and that is `pro_categories`. Demoting someone for + * typing "emergency callouts" would teach them to leave the field empty. + */ + updateSkills: proProcedure.input(updateSkillsSchema).mutation(async ({ ctx, input }) => { + const updated = await ctx.db + .update(schema.proProfiles) + .set({ skills: input.skills, updatedAt: new Date() }) + .where(eq(schema.proProfiles.userId, ctx.session.userId)) + .returning({ skills: schema.proProfiles.skills }); + + // proProcedure proves the caller is a pro, not that onboarding produced a row. + if (updated.length === 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Set up your pro profile before adding skills.', + }); + } + + return { skills: updated[0]!.skills }; + }), + + /** + * The caller's own deck card, at any verification status. + * + * `publicProfile` deliberately only returns `verified` pros, so a pro still in + * onboarding cannot use it to preview themselves — which is precisely when + * seeing the card matters most. This is that preview: same shape as DeckCard + * so the profile screen can render the real card component and the two cannot + * drift apart. + */ + previewCard: proProcedure.query(async ({ ctx }) => { + const profile = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + }); + if (!profile) return null; + + const [user] = await ctx.db + .select({ name: schema.users.name, image: schema.users.image }) + .from(schema.users) + .where(eq(schema.users.id, ctx.session.userId)); + + const [media, categories] = await Promise.all([ + ctx.db + .select({ url: schema.proMedia.url }) + .from(schema.proMedia) + .where(eq(schema.proMedia.proId, ctx.session.userId)) + .orderBy(schema.proMedia.position), + ctx.db + .select({ name: schema.categories.name }) + .from(schema.proCategories) + .innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId)) + .where(eq(schema.proCategories.proId, ctx.session.userId)), + ]); + + return { + proId: profile.userId, + name: user?.name ?? null, + image: user?.image ?? null, + headline: profile.headline, + bio: profile.bio, + hourlyRateCents: profile.hourlyRateCents, + yearsExperience: profile.yearsExperience, + ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg), + ratingCount: profile.ratingCount, + completedJobs: profile.completedJobs, + responseRate: profile.responseRate === null ? null : Number(profile.responseRate), + avgResponseMinutes: profile.avgResponseMinutes, + // A distance only exists relative to a client's job. There is no client + // here, so the preview shows a representative figure and the screen must + // label it as such rather than implying it is real. + distanceM: 2_400, + photos: media.map((m) => m.url), + categories: categories.map((c) => c.name), + score: 0, + verificationStatus: profile.verificationStatus, + isAcceptingJobs: profile.isAcceptingJobs, + }; + }), + + /** + * Reorder photos. + * + * Position 0 is the deck card — the single highest-conversion field a pro + * controls — so this is not cosmetic. + * + * Takes the full ordered id list rather than a move-one-item delta: a partial + * update would leave gaps or duplicate positions if a request were lost. + */ + reorderMedia: proProcedure + .input(z.object({ orderedIds: z.array(z.string().uuid()).min(1).max(10) })) + .mutation(async ({ ctx, input }) => { + const owned = await ctx.db + .select({ id: schema.proMedia.id }) + .from(schema.proMedia) + .where(eq(schema.proMedia.proId, ctx.session.userId)); + + const ownedIds = new Set(owned.map((m) => m.id)); + + // Every id must belong to the caller, and the list must be the WHOLE set — + // otherwise a caller could smuggle in someone else's photo id, or silently + // drop their own photos out of the ordering. + if (input.orderedIds.length !== ownedIds.size) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Send every photo id, in the new order.', + }); + } + for (const id of input.orderedIds) { + if (!ownedIds.has(id)) throw new TRPCError({ code: 'NOT_FOUND' }); + } + + await ctx.db.transaction(async (tx) => { + for (const [index, id] of input.orderedIds.entries()) { + await tx + .update(schema.proMedia) + .set({ position: index }) + .where( + and(eq(schema.proMedia.id, id), eq(schema.proMedia.proId, ctx.session.userId)), + ); + } + }); + + return { ordered: true }; + }), + /** Attach an uploaded photo. The file itself went straight to R2. */ addMedia: proProcedure .input( diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index 039fc2b..4dfb94a 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -1,9 +1,10 @@ +import { randomUUID } from 'node:crypto'; import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; +import { and, desc, eq, isNull } from 'drizzle-orm'; import { z } from 'zod'; import { schema } from '@linkder/db'; -import { isContactableEmail } from '@linkder/shared'; -import { protectedProcedure, router } from '../trpc'; +import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared'; +import { protectedProcedure, publicProcedure, router } from '../trpc'; export const userRouter = router({ /** Who am I — the shape the client needs to decide what to render. */ @@ -105,12 +106,21 @@ export const userRouter = router({ }), /** - * Set a real email address. + * Request a real email address. * * Required for pros — they need payout statements, tax records and dispute * notices, none of which can go to a synthetic phone address. + * + * The address is NOT written to `users.email` here. It is parked in + * email_change_requests until the token comes back, because `users.email` is + * UNIQUE: writing an unproven address would let anyone type a stranger's + * address and permanently block that stranger from signing up with Google. + * + * For the same reason this never reports that an address is already taken — + * that answer is an "is this person registered?" oracle. A collision is + * detected at confirm time, once ownership is proven. */ - setEmail: protectedProcedure + requestEmailChange: protectedProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { const email = input.email.trim().toLowerCase(); @@ -121,23 +131,302 @@ export const userRouter = router({ }); } + const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, ''); + + await ctx.db.insert(schema.emailChangeRequests).values({ + userId: ctx.session.userId, + email, + token, + expiresAt: new Date(Date.now() + 24 * 3_600_000), + }); + + // The caller learns only that we tried. Whether the address exists, is + // deliverable, or already belongs to someone else stays unobservable. + return { sent: true, token }; + }), + + /** + * Prove ownership and commit the address. + * + * Public rather than protected: the link is opened from an inbox, which may + * well be a different device with no session. The token is the credential. + */ + confirmEmailChange: publicProcedure + .input(z.object({ token: z.string().min(32) })) + .mutation(async ({ ctx, input }) => { + const request = await ctx.db.query.emailChangeRequests.findFirst({ + where: eq(schema.emailChangeRequests.token, input.token), + }); + + if (!request || request.consumedAt || request.expiresAt < new Date()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'That link is no longer valid.' }); + } + + // Checked here, not at request time: by now ownership is proven, so + // reporting the collision tells the real owner something true about their + // own address rather than leaking someone else's. const taken = await ctx.db.query.users.findFirst({ - where: eq(schema.users.email, email), + where: eq(schema.users.email, request.email), columns: { id: true }, }); - if (taken && taken.id !== ctx.session.userId) { + if (taken && taken.id !== request.userId) { throw new TRPCError({ code: 'CONFLICT', message: 'Another account already uses that email address.', }); } + await ctx.db.transaction(async (tx) => { + await tx + .update(schema.users) + .set({ email: request.email, emailVerified: true, updatedAt: new Date() }) + .where(eq(schema.users.id, request.userId)); + + await tx + .update(schema.emailChangeRequests) + .set({ consumedAt: new Date() }) + .where(eq(schema.emailChangeRequests.id, request.id)); + }); + + return { email: request.email }; + }), + + /** Name and avatar. Everything else on the account has its own procedure. */ + updateProfile: protectedProcedure + .input( + z.object({ + name: z.string().trim().min(1).max(80).optional(), + image: z.string().url().max(500).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.name === undefined && input.image === undefined) return { updated: false }; + await ctx.db .update(schema.users) - .set({ email, emailVerified: false, updatedAt: new Date() }) + .set({ + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.image !== undefined ? { image: input.image } : {}), + updatedAt: new Date(), + }) .where(eq(schema.users.id, ctx.session.userId)); - // TODO(M1): send a confirmation link before treating it as verified. - return { email }; + return { updated: true }; + }), + + /** + * "Where am I, and how far am I looking?" + * + * Reads from whichever table actually decides matching for this caller. A + * verified pro is matched on `pro_profiles.base_location` + `service_radius_m` + * — showing them `users.search_radius_m` instead would be a settings screen + * that displays a number nothing acts on. + */ + location: protectedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(schema.users.id, ctx.session.userId), + columns: { location: true, locationText: true, searchRadiusM: true }, + }); + if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); + + const profile = + ctx.session.role === 'pro' + ? await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + columns: { baseLocation: true, serviceRadiusM: true, verificationStatus: true }, + }) + : undefined; + + return { + /** Which record this screen is editing — the copy differs, and so does the effect. */ + scope: profile ? ('pro' as const) : ('client' as const), + /** A pro who has not finished onboarding has no service area to edit yet. */ + needsProfile: ctx.session.role === 'pro' && !profile, + location: profile ? profile.baseLocation : user.location, + addressText: user.locationText, + radiusM: profile ? profile.serviceRadiusM : user.searchRadiusM, + /** Warn before the save, not after: this is what costs them their badge. */ + reviewOnChange: profile?.verificationStatus === 'verified', + }; + }), + + /** + * Move the pin, or change the range. + * + * Routed by role for the reason above. For a pro this is the same material + * change as editing the area in the wizard, so it carries the same + * consequence — a verified profile goes back to pending. Doing anything else + * would make settings the way around verification. + */ + updateLocation: protectedProcedure + .input(updateLocationSchema) + .mutation(async ({ ctx, input }) => { + const profile = + ctx.session.role === 'pro' + ? await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + }) + : undefined; + + // The label is a display string with no matching role, so it lives on the + // user for everyone rather than being duplicated per role. + if (input.addressText !== undefined) { + await ctx.db + .update(schema.users) + .set({ locationText: input.addressText || null, updatedAt: new Date() }) + .where(eq(schema.users.id, ctx.session.userId)); + } + + if (!profile) { + if (input.location !== undefined || input.radiusM !== undefined) { + await ctx.db + .update(schema.users) + .set({ + ...(input.location !== undefined ? { location: input.location } : {}), + ...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}), + updatedAt: new Date(), + }) + .where(eq(schema.users.id, ctx.session.userId)); + } + return { scope: 'client' as const, sentForReview: false }; + } + + const moved = + input.location !== undefined && + (input.location.lat !== profile.baseLocation.lat || + input.location.lng !== profile.baseLocation.lng); + const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM; + + // Only a currently-verified pro needs demoting: a draft or pending profile + // is not on the deck anyway, and demoting a suspended one would quietly + // undo a moderator. + const sendBackForReview = + (moved || resized) && profile.verificationStatus === 'verified'; + if (sendBackForReview) assertTransition('verification', 'verified', 'pending'); + + if (moved || resized) { + await ctx.db.transaction(async (tx) => { + await tx + .update(schema.proProfiles) + .set({ + ...(input.location !== undefined ? { baseLocation: input.location } : {}), + ...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}), + ...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}), + updatedAt: new Date(), + }) + .where(eq(schema.proProfiles.userId, ctx.session.userId)); + + if (sendBackForReview) { + await tx.insert(schema.auditLog).values({ + actorId: ctx.session.userId, + action: 'verification.re_review_required', + entity: 'pro_profile', + entityId: ctx.session.userId, + metadata: { reason: 'service_area_changed', via: 'settings' }, + ip: ctx.ip, + }); + } + }); + } + + return { scope: 'pro' as const, sentForReview: sendBackForReview }; + }), + + /** Signed-in devices, for the security screen. Never another user's. */ + sessions: protectedProcedure.query(async ({ ctx }) => { + const rows = await ctx.db + .select({ + id: schema.sessions.id, + userAgent: schema.sessions.userAgent, + ipAddress: schema.sessions.ipAddress, + createdAt: schema.sessions.createdAt, + expiresAt: schema.sessions.expiresAt, + impersonatedBy: schema.sessions.impersonatedBy, + }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, ctx.session.userId)) + .orderBy(desc(schema.sessions.createdAt)); + + // sessions.token is a live bearer credential and is deliberately not selected. + return rows.map((r) => ({ ...r, isImpersonated: r.impersonatedBy !== null })); + }), + + /** + * GDPR access request: everything we hold about the caller, as JSON. + * Scoped by userId throughout — this must never become a way to read + * someone else's rows. + */ + exportData: protectedProcedure.query(async ({ ctx }) => { + const uid = ctx.session.userId; + + const [user, jobs, swipes, requests, proProfile, preferences] = await Promise.all([ + ctx.db.query.users.findFirst({ + where: eq(schema.users.id, uid), + columns: { + id: true, + name: true, + email: true, + phoneNumber: true, + image: true, + role: true, + location: true, + locationText: true, + searchRadiusM: true, + createdAt: true, + }, + }), + ctx.db.select().from(schema.jobs).where(eq(schema.jobs.clientId, uid)), + ctx.db.select().from(schema.swipes).where(eq(schema.swipes.proId, uid)), + ctx.db.select().from(schema.requests).where(eq(schema.requests.proId, uid)), + ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, uid) }), + ctx.db.query.notificationPreferences.findFirst({ + where: eq(schema.notificationPreferences.userId, uid), + }), + ]); + + return { + exportedAt: new Date().toISOString(), + user, + proProfile: proProfile ?? null, + jobs, + swipes, + requests, + notificationPreferences: preferences ?? null, + }; + }), + + /** + * GDPR erasure request. + * + * Records the ask rather than deleting: bookings, payments and reviews carry + * foreign keys and statutory retention periods, so a cascade would destroy + * records we are required to keep. A human actions this. It needs a real + * anonymise-and-retain flow before there are real users. + */ + requestDeletion: protectedProcedure + .input(z.object({ reason: z.string().max(1000).optional() })) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.db.query.deletionRequests.findFirst({ + where: and( + eq(schema.deletionRequests.userId, ctx.session.userId), + isNull(schema.deletionRequests.actionedAt), + ), + }); + if (existing) return { requested: true, alreadyPending: true }; + + await ctx.db.insert(schema.deletionRequests).values({ + userId: ctx.session.userId, + reason: input.reason ?? null, + }); + + await ctx.db.insert(schema.auditLog).values({ + actorId: ctx.session.userId, + action: 'user.deletion_requested', + entity: 'user', + entityId: ctx.session.userId, + ip: ctx.ip, + }); + + return { requested: true, alreadyPending: false }; }), }); diff --git a/packages/api/test/pro.router.test.ts b/packages/api/test/pro.router.test.ts new file mode 100644 index 0000000..e8b4edb --- /dev/null +++ b/packages/api/test/pro.router.test.ts @@ -0,0 +1,248 @@ +/** + * Integration tests for the pro profile surface. + * + * pnpm services:up && pnpm db:migrate && pnpm db:seed + * + * previewCard exists precisely because publicProfile refuses non-verified pros, + * so most of what follows is about it working where publicProfile cannot, and + * about reordering never reaching another pro's photos. + */ +import { config } from 'dotenv'; +import { sql } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +config({ path: '../../.env' }); + +const { closePool, db } = await import('@linkder/db'); +const { appRouter } = await import('../src/root'); +const { createInnerContext } = await import('../src/context'); +const { createCallerFactory } = await import('../src/trpc'); + +const createCaller = createCallerFactory(appRouter); +type Session = import('../src/context').Session; + +function callerFor(session: Session | null) { + return createCaller(createInnerContext({ db, session })); +} + +const proSession = (userId: string, verificationStatus: string): Session => ({ + userId, + role: 'pro', + name: 'Test Pro', + email: 'pro@test', + phone: null, + verificationStatus: verificationStatus as Session['verificationStatus'], +}); + +let verifiedPro: string; +let pendingPro: string; +let otherPro: string; + +beforeAll(async () => { + const verified = await db.execute<{ user_id: string }>(sql` + SELECT p.user_id FROM pro_profiles p + WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2 + `); + verifiedPro = verified[0]!.user_id; + otherPro = verified[1]!.user_id; + + const pending = await db.execute<{ user_id: string }>(sql` + SELECT p.user_id FROM pro_profiles p + WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1 + `); + pendingPro = pending[0]!.user_id; +}); + +afterAll(async () => { + // The skills tests write to real seeded profiles; put them back empty. + await db.execute( + sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`, + ); + await closePool(); +}); + +describe('pro.previewCard', () => { + it('returns the caller’s own card', async () => { + const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard(); + expect(card?.proId).toBe(verifiedPro); + expect(card?.headline).toBeTruthy(); + }); + + it('works for a pro whose verification has NOT passed', async () => { + // publicProfile 404s here, which is why this procedure exists: the moment a + // pro most needs to see their card is before they are approved. + const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard(); + expect(card?.proId).toBe(pendingPro); + + await expect( + callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }), + ).rejects.toThrow(); + }); + + it('carries the photos in deck order, lead photo first', async () => { + const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard(); + const rows = await db.execute<{ url: string }>(sql` + SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + expect(card?.photos[0]).toBe(rows[0]!.url); + }); + + it('is not reachable by a client', async () => { + await expect( + callerFor({ + userId: verifiedPro, + role: 'client', + name: null, + email: null, + phone: null, + verificationStatus: null, + }).pro.previewCard(), + ).rejects.toThrow(); + }); +}); + +describe('pro.reorderMedia', () => { + it('puts the chosen photo first', async () => { + const before = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + const reversed = before.map((r) => r.id).reverse(); + + await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ + orderedIds: reversed, + }); + + const after = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + expect(after.map((r) => r.id)).toEqual(reversed); + }); + + it('refuses a list containing another pro’s photo', async () => { + const mine = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + const theirs = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1 + `); + + const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id]; + await expect( + callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }), + ).rejects.toThrow(); + }); + + it('refuses a partial list, which would leave gaps in the ordering', async () => { + const mine = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + await expect( + callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ + orderedIds: [mine[0]!.id], + }), + ).rejects.toThrow(); + }); + + it('leaves the other pro’s photos untouched', async () => { + const theirsBefore = await db.execute<{ id: string; position: number }>(sql` + SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position + `); + const mine = await db.execute<{ id: string }>(sql` + SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position + `); + + await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ + orderedIds: mine.map((r) => r.id).reverse(), + }); + + const theirsAfter = await db.execute<{ id: string; position: number }>(sql` + SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position + `); + expect(theirsAfter).toEqual(theirsBefore); + }); +}); + +describe('pro.updateSkills', () => { + it('saves the list and hands it back on pro.me', async () => { + const caller = callerFor(proSession(verifiedPro, 'verified')); + const result = await caller.pro.updateSkills({ + skills: ['Underfloor heating', 'Emergency callouts'], + }); + expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']); + + const profile = await caller.pro.me(); + expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']); + }); + + it('replaces the whole list rather than appending', async () => { + const caller = callerFor(proSession(verifiedPro, 'verified')); + const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] }); + expect(result.skills).toEqual(['Bathroom fitting']); + }); + + it('trims and drops case-insensitive duplicates', async () => { + const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ + skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'], + }); + // First spelling wins; the rest are the same claim twice. + expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']); + }); + + it('refuses more than the cap, and entries that are too long', async () => { + const caller = callerFor(proSession(verifiedPro, 'verified')); + await expect( + caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }), + ).rejects.toThrow(); + await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow(); + await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow(); + }); + + it('accepts an empty list, so a pro can clear it', async () => { + const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ + skills: [], + }); + expect(result.skills).toEqual([]); + }); + + it('does NOT send a verified pro back for review', async () => { + await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ + skills: ['Listed buildings'], + }); + + const rows = await db.execute<{ status: string }>( + sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`, + ); + // Skills are description, not a licensed claim — demoting for one would + // just teach pros to leave the field empty. + expect(rows[0]!.status).toBe('verified'); + }); + + it('works before verification has passed', async () => { + const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({ + skills: ['Rewiring'], + }); + expect(result.skills).toEqual(['Rewiring']); + }); + + it('never touches another pro row', async () => { + await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] }); + + const rows = await db.execute<{ skills: string[] }>( + sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`, + ); + expect(rows[0]!.skills).not.toContain('Mine'); + }); + + it('rejects a caller who is not a pro', async () => { + const client: Session = { + userId: verifiedPro, + role: 'client', + name: 'Test Client', + email: 'client@test', + phone: null, + verificationStatus: null, + }; + await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow(); + await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow(); + }); +}); diff --git a/packages/api/test/settings.router.test.ts b/packages/api/test/settings.router.test.ts new file mode 100644 index 0000000..28849e1 --- /dev/null +++ b/packages/api/test/settings.router.test.ts @@ -0,0 +1,358 @@ +/** + * Integration tests for the settings surface, against the live seeded database. + * + * pnpm services:up && pnpm db:migrate && pnpm db:seed + * + * These are mostly authorization and information-leak tests. Settings hands a + * user controls over their own account; the failure mode that matters is one of + * them reaching somebody else's. + */ +import { config } from 'dotenv'; +import { sql } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +config({ path: '../../.env' }); + +const { closePool, db } = await import('@linkder/db'); +const { appRouter } = await import('../src/root'); +const { createInnerContext } = await import('../src/context'); +const { createCallerFactory } = await import('../src/trpc'); + +const createCaller = createCallerFactory(appRouter); +type Session = import('../src/context').Session; + +function callerFor(session: Session | null) { + return createCaller(createInnerContext({ db, session })); +} + +const clientSession = (userId: string): Session => ({ + userId, + role: 'client', + name: 'Test Client', + email: 'client@test', + phone: null, + verificationStatus: null, +}); + +const proSession = (userId: string): Session => ({ + userId, + role: 'pro', + name: 'Test Pro', + email: 'pro@test', + phone: null, + verificationStatus: 'verified', +}); + +let alice: string; +let bob: string; +let aliceEmail: string; +let bobEmail: string; + +/** + * A throwaway verified pro, owned by this file. + * + * Not a seeded one: vitest runs test FILES in parallel, and the location tests + * demote a verified pro to `pending` — doing that to a seeded pro would delete a + * card out from under deck.router.test.ts mid-run. This one is parked in the + * Gulf of Guinea with no trades, so no deck query can reach it either way. + */ +let pro: string; +const PRO_BASE = { lat: 0.5, lng: 0.5 }; + +// Unique per run: these tests write real addresses onto real rows, and a +// leftover from a previous run would collide with users.email's UNIQUE index. +const RUN = Math.random().toString(36).slice(2, 8); + +/** Marks the session rows this file creates, so they can be cleaned up. */ +const PROBE_UA = 'SettingsTestProbe'; + +beforeAll(async () => { + // Order by id, not created_at: the seed writes clients in one batch and + // created_at ties, so created_at ordering is not stable between runs. + const rows = await db.execute<{ id: string; email: string }>( + sql`SELECT id, email FROM users WHERE role = 'client' ORDER BY id LIMIT 2`, + ); + alice = rows[0]!.id; + bob = rows[1]!.id; + aliceEmail = rows[0]!.email; + bobEmail = rows[1]!.email; + + await db.execute(sql`DELETE FROM email_change_requests`); + await db.execute(sql`DELETE FROM deletion_requests`); + await db.execute(sql`DELETE FROM notification_preferences`); + await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`); + + const created = await db.execute<{ id: string }>(sql` + INSERT INTO users (name, email, role) + VALUES ('Location Probe', ${`location-probe-${RUN}@example.com`}, 'pro') + RETURNING id + `); + pro = created[0]!.id; + await db.execute(sql` + INSERT INTO pro_profiles ( + user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m, + verification_status, verified_at + ) + VALUES ( + ${pro}, 'Location probe', 'Exists only for the settings location tests.', 3000, + ST_SetSRID(ST_MakePoint(${PRO_BASE.lng}, ${PRO_BASE.lat}), 4326)::geography, 15000, + 'verified', now() + ) + `); +}); + +afterAll(async () => { + // Put the addresses back, or the next run starts from a different state. + await db.execute(sql`UPDATE users SET email = ${aliceEmail} WHERE id = ${alice}`); + await db.execute(sql`UPDATE users SET email = ${bobEmail} WHERE id = ${bob}`); + await db.execute(sql`DELETE FROM email_change_requests`); + await db.execute(sql`DELETE FROM deletion_requests`); + await db.execute(sql`DELETE FROM notification_preferences`); + await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`); + // Cascades to pro_profiles and audit_log. + await db.execute(sql`DELETE FROM users WHERE id = ${pro}`); + await db.execute(sql` + UPDATE users SET location = NULL, location_text = NULL, search_radius_m = 15000 + WHERE id IN (${alice}, ${bob}) + `); + await closePool(); +}); + +describe('notification preferences', () => { + it('returns defaults when the user has never saved any', async () => { + const prefs = await callerFor(clientSession(alice)).notification.get(); + expect(prefs.smsNewRequest).toBe(true); + // Marketing is the one that must default OFF — opt-in, not opt-out. + expect(prefs.smsMarketing).toBe(false); + expect(prefs.emailMarketing).toBe(false); + }); + + it('a partial update does not reset the preferences it did not mention', async () => { + const caller = callerFor(clientSession(alice)); + await caller.notification.update({ smsMarketing: true }); + await caller.notification.update({ smsNewRequest: false }); + + const prefs = await caller.notification.get(); + expect(prefs.smsMarketing).toBe(true); + expect(prefs.smsNewRequest).toBe(false); + }); + + it("one user's preferences are invisible to another", async () => { + await callerFor(clientSession(alice)).notification.update({ pushMessages: false }); + const bobPrefs = await callerFor(clientSession(bob)).notification.get(); + expect(bobPrefs.pushMessages).toBe(true); + }); + + it('rejects an anonymous caller', async () => { + await expect(callerFor(null).notification.get()).rejects.toThrow(); + }); +}); + +describe('email change', () => { + it('does NOT write the address to users.email before it is confirmed', async () => { + const caller = callerFor(clientSession(alice)); + await caller.user.requestEmailChange({ email: `claimed-${RUN}@example.com` }); + + const rows = await db.execute<{ count: number }>( + sql`SELECT count(*)::int AS count FROM users WHERE email = ${`claimed-${RUN}@example.com`}`, + ); + // This is the whole point: an unproven address must not occupy the UNIQUE + // column, or its real owner can never sign up with Google. + expect(rows[0]!.count).toBe(0); + }); + + it('does not reveal whether an address is already registered', async () => { + // Requesting someone else's address must look exactly like any other request. + await expect( + callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail }), + ).resolves.toMatchObject({ sent: true }); + }); + + it('commits the address once the token comes back', async () => { + const caller = callerFor(clientSession(alice)); + const { token } = await caller.user.requestEmailChange({ email: `proven-${RUN}@example.com` }); + + await callerFor(null).user.confirmEmailChange({ token }); + + const rows = await db.execute<{ email: string; verified: boolean }>( + sql`SELECT email, email_verified AS verified FROM users WHERE id = ${alice}`, + ); + expect(rows[0]!.email).toBe(`proven-${RUN}@example.com`); + expect(rows[0]!.verified).toBe(true); + }); + + it('refuses a token twice', async () => { + const caller = callerFor(clientSession(alice)); + const { token } = await caller.user.requestEmailChange({ email: `once-${RUN}@example.com` }); + await callerFor(null).user.confirmEmailChange({ token }); + await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(); + }); + + it('refuses to commit an address another account already holds', async () => { + const { token } = await callerFor(clientSession(alice)).user.requestEmailChange({ + email: bobEmail, + }); + // Only now — ownership proven — is the collision reported. + await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(/already/i); + }); +}); + +describe('sessions', () => { + it('never returns another user’s sessions', async () => { + await db.execute(sql` + INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent) + VALUES (${bob}, ${`bob-token-${RUN}`}, now() + interval '1 day', '10.0.0.1', ${PROBE_UA}) + `); + const aliceSessions = await callerFor(clientSession(alice)).user.sessions(); + expect(aliceSessions.every((s) => s.userAgent !== PROBE_UA)).toBe(true); + }); + + it('never returns the session token', async () => { + const rows = await callerFor(clientSession(bob)).user.sessions(); + for (const row of rows) { + expect(Object.keys(row)).not.toContain('token'); + } + }); +}); + +describe('deletion request', () => { + it('records a request without deleting the user', async () => { + const result = await callerFor(clientSession(bob)).user.requestDeletion({ reason: 'testing' }); + expect(result.requested).toBe(true); + + const still = await db.execute<{ count: number }>( + sql`SELECT count(*)::int AS count FROM users WHERE id = ${bob}`, + ); + expect(still[0]!.count).toBe(1); + }); + + it('is idempotent while one is still outstanding', async () => { + const second = await callerFor(clientSession(bob)).user.requestDeletion({}); + expect(second.alreadyPending).toBe(true); + }); +}); + +describe('location and range', () => { + it('starts with no pin and the default radius', async () => { + const location = await callerFor(clientSession(bob)).user.location(); + expect(location.scope).toBe('client'); + expect(location.location).toBeNull(); + expect(location.radiusM).toBe(15_000); + }); + + it('saves a pin, a label and a radius, and reads them back', async () => { + const caller = callerFor(clientSession(alice)); + await caller.user.updateLocation({ + location: { lat: 41.4036, lng: 2.1744 }, + addressText: 'Gracia, Barcelona', + radiusM: 8_000, + }); + + const location = await caller.user.location(); + expect(location.addressText).toBe('Gracia, Barcelona'); + expect(location.radiusM).toBe(8_000); + expect(location.location?.lat).toBeCloseTo(41.4036, 4); + expect(location.location?.lng).toBeCloseTo(2.1744, 4); + }); + + it('changes only what it was given', async () => { + const caller = callerFor(clientSession(alice)); + await caller.user.updateLocation({ radiusM: 25_000 }); + + const location = await caller.user.location(); + expect(location.radiusM).toBe(25_000); + // The pin saved by the previous test is still there. + expect(location.location?.lat).toBeCloseTo(41.4036, 4); + }); + + it('refuses a radius outside the supported range', async () => { + const caller = callerFor(clientSession(alice)); + await expect(caller.user.updateLocation({ radiusM: 500_000 })).rejects.toThrow(); + await expect(caller.user.updateLocation({ radiusM: 10 })).rejects.toThrow(); + }); + + it('refuses an update that says nothing', async () => { + await expect(callerFor(clientSession(alice)).user.updateLocation({})).rejects.toThrow(); + }); + + it('never reads or writes another user location', async () => { + await callerFor(clientSession(alice)).user.updateLocation({ radiusM: 3_000 }); + const bobLocation = await callerFor(clientSession(bob)).user.location(); + expect(bobLocation.radiusM).not.toBe(3_000); + }); + + it('rejects an anonymous caller', async () => { + await expect(callerFor(null).user.location()).rejects.toThrow(); + await expect(callerFor(null).user.updateLocation({ radiusM: 5_000 })).rejects.toThrow(); + }); + + it('reads a pro service area from the profile, not the user row', async () => { + // A stray value on the user row must not be what a pro is shown: the deck + // matches on the profile, so anything else would display a number that + // decides nothing. + await db.execute(sql`UPDATE users SET search_radius_m = 1000 WHERE id = ${pro}`); + + const location = await callerFor(proSession(pro)).user.location(); + expect(location.scope).toBe('pro'); + expect(location.needsProfile).toBe(false); + expect(location.radiusM).toBe(15_000); + expect(location.location?.lat).toBeCloseTo(PRO_BASE.lat, 4); + expect(location.reviewOnChange).toBe(true); + }); + + it('writes a pro radius to the profile the deck reads', async () => { + await callerFor(proSession(pro)).user.updateLocation({ radiusM: 22_000 }); + + const rows = await db.execute<{ radius: number }>( + sql`SELECT service_radius_m AS radius FROM pro_profiles WHERE user_id = ${pro}`, + ); + expect(rows[0]!.radius).toBe(22_000); + }); + + it('sends a verified pro back for review when the area changes', async () => { + await db.execute( + sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`, + ); + + const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000 }); + expect(result.sentForReview).toBe(true); + + const rows = await db.execute<{ status: string }>( + sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`, + ); + // Settings must not become the way around verification. + expect(rows[0]!.status).toBe('pending'); + + const audit = await db.execute<{ count: number }>(sql` + SELECT count(*)::int AS count FROM audit_log + WHERE actor_id = ${pro} AND action = 'verification.re_review_required' + `); + expect(audit[0]!.count).toBeGreaterThan(0); + }); + + it('leaves verification alone when nothing material changed', async () => { + await db.execute( + sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`, + ); + + // The radius the row already holds, plus a label. Neither is material. + const result = await callerFor(proSession(pro)).user.updateLocation({ + radiusM: 30_000, + addressText: 'Somewhere warm', + }); + expect(result.sentForReview).toBe(false); + + const rows = await db.execute<{ status: string }>( + sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`, + ); + expect(rows[0]!.status).toBe('verified'); + }); +}); + +describe('data export', () => { + it('returns only the caller’s own rows', async () => { + const data = await callerFor(clientSession(alice)).user.exportData(); + expect(data.user?.id).toBe(alice); + expect(data.jobs.every((j) => j.clientId === alice)).toBe(true); + }); +}); diff --git a/packages/db/drizzle/0001_bouncy_sally_floyd.sql b/packages/db/drizzle/0001_bouncy_sally_floyd.sql new file mode 100644 index 0000000..f1d0c05 --- /dev/null +++ b/packages/db/drizzle/0001_bouncy_sally_floyd.sql @@ -0,0 +1,22 @@ +CREATE TABLE "deletion_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "reason" text, + "actioned_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_preferences" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "sms_new_request" boolean DEFAULT true NOT NULL, + "sms_booking_reminder" boolean DEFAULT true NOT NULL, + "sms_marketing" boolean DEFAULT false NOT NULL, + "email_receipts" boolean DEFAULT true NOT NULL, + "email_marketing" boolean DEFAULT false NOT NULL, + "push_messages" boolean DEFAULT true NOT NULL, + "push_requests" boolean DEFAULT true NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "deletion_requests" ADD CONSTRAINT "deletion_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/0002_jazzy_mac_gargan.sql b/packages/db/drizzle/0002_jazzy_mac_gargan.sql new file mode 100644 index 0000000..4035f83 --- /dev/null +++ b/packages/db/drizzle/0002_jazzy_mac_gargan.sql @@ -0,0 +1,12 @@ +CREATE TABLE "email_change_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "email" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "email_change_requests_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "email_change_requests" ADD CONSTRAINT "email_change_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/0003_amazing_turbo.sql b/packages/db/drizzle/0003_amazing_turbo.sql new file mode 100644 index 0000000..6f99584 --- /dev/null +++ b/packages/db/drizzle/0003_amazing_turbo.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN "location" geography(Point,4326);--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "location_text" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "search_radius_m" integer DEFAULT 15000 NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/0004_closed_nextwave.sql b/packages/db/drizzle/0004_closed_nextwave.sql new file mode 100644 index 0000000..d4d5027 --- /dev/null +++ b/packages/db/drizzle/0004_closed_nextwave.sql @@ -0,0 +1 @@ +ALTER TABLE "pro_profiles" ADD COLUMN "skills" text[] DEFAULT '{}'::text[] NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..9b098f8 --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,3061 @@ +{ + "id": "3f42462c-0585-4a9a-996c-3434f22a56ac", + "prevId": "6b9ea99a-a893-432a-9bea-ab3e145c97a2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_issuer_account_unique": { + "name": "accounts_issuer_account_unique", + "nullsNotDistinct": false, + "columns": [ + "issuer", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_impersonated_by_users_id_fk": { + "name": "sessions_impersonated_by_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_phone_idx": { + "name": "users_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_unique": { + "name": "users_phone_unique", + "nullsNotDistinct": false, + "columns": [ + "phone" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_notes": { + "name": "review_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_pro_idx": { + "name": "credentials_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_review_idx": { + "name": "credentials_review_idx", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_expiry_idx": { + "name": "credentials_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credentials_pro_id_pro_profiles_user_id_fk": { + "name": "credentials_pro_id_pro_profiles_user_id_fk", + "tableFrom": "credentials", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credentials_reviewed_by_users_id_fk": { + "name": "credentials_reviewed_by_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_availability": { + "name": "pro_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute": { + "name": "end_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_availability_pro_idx": { + "name": "pro_availability_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weekday", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_availability_pro_id_pro_profiles_user_id_fk": { + "name": "pro_availability_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_availability", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_categories": { + "name": "pro_categories", + "schema": "", + "columns": { + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_categories_category_idx": { + "name": "pro_categories_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_categories_pro_id_pro_profiles_user_id_fk": { + "name": "pro_categories_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_categories", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pro_categories_category_id_categories_id_fk": { + "name": "pro_categories_category_id_categories_id_fk", + "tableFrom": "pro_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pro_categories_pro_id_category_id_pk": { + "name": "pro_categories_pro_id_category_id_pk", + "columns": [ + "pro_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_media": { + "name": "pro_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'photo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_media_pro_idx": { + "name": "pro_media_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_media_pro_id_pro_profiles_user_id_fk": { + "name": "pro_media_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_media", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_profiles": { + "name": "pro_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hourly_rate_cents": { + "name": "hourly_rate_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_experience": { + "name": "years_experience", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_location": { + "name": "base_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "service_radius_m": { + "name": "service_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_accepting_jobs": { + "name": "is_accepting_jobs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_jobs": { + "name": "completed_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_rate": { + "name": "response_rate", + "type": "numeric(4, 3)", + "primaryKey": false, + "notNull": false + }, + "avg_response_minutes": { + "name": "avg_response_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_account_id": { + "name": "stripe_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payouts_enabled": { + "name": "stripe_payouts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_profiles_location_gist": { + "name": "pro_profiles_location_gist", + "columns": [ + { + "expression": "base_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "pro_profiles_deck_idx": { + "name": "pro_profiles_deck_idx", + "columns": [ + { + "expression": "verification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_accepting_jobs", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pro_profiles\".\"verification_status\" = 'verified' AND \"pro_profiles\".\"is_accepting_jobs\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_profiles_user_id_users_id_fk": { + "name": "pro_profiles_user_id_users_id_fk", + "tableFrom": "pro_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pro_profiles_stripe_account_id_unique": { + "name": "pro_profiles_stripe_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_sessions": { + "name": "verification_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'didit'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_sessions_pro_idx": { + "name": "verification_sessions_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_sessions_pro_id_pro_profiles_user_id_fk": { + "name": "verification_sessions_pro_id_pro_profiles_user_id_fk", + "tableFrom": "verification_sessions", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_sessions_external_id_unique": { + "name": "verification_sessions_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photos": { + "name": "photos", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "urgency": { + "name": "urgency", + "type": "urgency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "budget_min_cents": { + "name": "budget_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_max_cents": { + "name": "budget_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address_text": { + "name": "address_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_location_gist": { + "name": "jobs_location_gist", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "jobs_client_idx": { + "name": "jobs_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_open_idx": { + "name": "jobs_open_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_client_id_users_id_fk": { + "name": "jobs_client_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jobs_category_id_categories_id_fk": { + "name": "jobs_category_id_categories_id_fk", + "tableFrom": "jobs", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "matches_pro_idx": { + "name": "matches_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_client_idx": { + "name": "matches_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_job_idx": { + "name": "matches_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_request_id_requests_id_fk": { + "name": "matches_request_id_requests_id_fk", + "tableFrom": "matches", + "tableTo": "requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_job_id_jobs_id_fk": { + "name": "matches_job_id_jobs_id_fk", + "tableFrom": "matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_pro_id_pro_profiles_user_id_fk": { + "name": "matches_pro_id_pro_profiles_user_id_fk", + "tableFrom": "matches", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_client_id_users_id_fk": { + "name": "matches_client_id_users_id_fk", + "tableFrom": "matches", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "matches_request_id_unique": { + "name": "matches_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_pending_idx": { + "name": "requests_pending_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_job_idx": { + "name": "requests_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_job_id_jobs_id_fk": { + "name": "requests_job_id_jobs_id_fk", + "tableFrom": "requests", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "requests_pro_id_pro_profiles_user_id_fk": { + "name": "requests_pro_id_pro_profiles_user_id_fk", + "tableFrom": "requests", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_job_pro_unique": { + "name": "requests_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swipes": { + "name": "swipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "swipe_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swipes_job_idx": { + "name": "swipes_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "swipes_job_id_jobs_id_fk": { + "name": "swipes_job_id_jobs_id_fk", + "tableFrom": "swipes", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "swipes_pro_id_pro_profiles_user_id_fk": { + "name": "swipes_pro_id_pro_profiles_user_id_fk", + "tableFrom": "swipes", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swipes_job_pro_unique": { + "name": "swipes_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_match_idx": { + "name": "messages_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_unread_idx": { + "name": "messages_unread_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"read_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_match_id_matches_id_fk": { + "name": "messages_match_id_matches_id_fk", + "tableFrom": "messages", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookings": { + "name": "bookings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_id": { + "name": "quote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_start": { + "name": "scheduled_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scheduled_end": { + "name": "scheduled_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "pro_completed_at": { + "name": "pro_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_confirmed_at": { + "name": "client_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_by": { + "name": "cancelled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookings_match_idx": { + "name": "bookings_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_status_idx": { + "name": "bookings_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pro_completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_schedule_idx": { + "name": "bookings_schedule_idx", + "columns": [ + { + "expression": "scheduled_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_match_id_matches_id_fk": { + "name": "bookings_match_id_matches_id_fk", + "tableFrom": "bookings", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookings_quote_id_quotes_id_fk": { + "name": "bookings_quote_id_quotes_id_fk", + "tableFrom": "bookings", + "tableTo": "quotes", + "columnsFrom": [ + "quote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_cancelled_by_users_id_fk": { + "name": "bookings_cancelled_by_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "cancelled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_cents": { + "name": "platform_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_bps": { + "name": "platform_fee_bps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "refunded_cents": { + "name": "refunded_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eur'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payments_status_idx": { + "name": "payments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_intent_idx": { + "name": "payments_intent_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_booking_id_bookings_id_fk": { + "name": "payments_booking_id_bookings_id_fk", + "tableFrom": "payments", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_booking_id_unique": { + "name": "payments_booking_id_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id" + ] + }, + "payments_stripe_payment_intent_id_unique": { + "name": "payments_stripe_payment_intent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_payment_intent_id" + ] + }, + "payments_stripe_transfer_id_unique": { + "name": "payments_stripe_transfer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_transfer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_stripe_events": { + "name": "processed_stripe_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quotes": { + "name": "quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "quote_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hours_estimate": { + "name": "hours_estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quotes_match_idx": { + "name": "quotes_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quotes_match_id_matches_id_fk": { + "name": "quotes_match_id_matches_id_fk", + "tableFrom": "quotes", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_subject_idx": { + "name": "reviews_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_booking_id_bookings_id_fk": { + "name": "reviews_booking_id_bookings_id_fk", + "tableFrom": "reviews", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_author_id_users_id_fk": { + "name": "reviews_author_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_subject_id_users_id_fk": { + "name": "reviews_subject_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_booking_author_unique": { + "name": "reviews_booking_author_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id", + "author_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_requests": { + "name": "deletion_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_requests_user_id_users_id_fk": { + "name": "deletion_requests_user_id_users_id_fk", + "tableFrom": "deletion_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sms_new_request": { + "name": "sms_new_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_booking_reminder": { + "name": "sms_booking_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_marketing": { + "name": "sms_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_receipts": { + "name": "email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_marketing": { + "name": "email_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "push_messages": { + "name": "push_messages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_requests": { + "name": "push_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.booking_status": { + "name": "booking_status", + "schema": "public", + "values": [ + "scheduled", + "in_progress", + "awaiting_confirmation", + "completed", + "cancelled", + "disputed" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "id", + "licence", + "insurance" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "open", + "matched", + "booked", + "completed", + "cancelled" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "photo", + "work_sample" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "held", + "released", + "refunded", + "partially_refunded", + "failed" + ] + }, + "public.quote_kind": { + "name": "quote_kind", + "schema": "public", + "values": [ + "fixed", + "hourly" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "sent", + "accepted", + "declined", + "withdrawn", + "expired" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.swipe_direction": { + "name": "swipe_direction", + "schema": "public", + "values": [ + "left", + "right" + ] + }, + "public.urgency": { + "name": "urgency", + "schema": "public", + "values": [ + "now", + "this_week", + "flexible" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "client", + "pro", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "draft", + "pending", + "verified", + "rejected", + "suspended" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7f08fc6 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,3140 @@ +{ + "id": "c22f2308-57f8-4ed0-ad77-3f56092335a9", + "prevId": "3f42462c-0585-4a9a-996c-3434f22a56ac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_issuer_account_unique": { + "name": "accounts_issuer_account_unique", + "nullsNotDistinct": false, + "columns": [ + "issuer", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_impersonated_by_users_id_fk": { + "name": "sessions_impersonated_by_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_phone_idx": { + "name": "users_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_unique": { + "name": "users_phone_unique", + "nullsNotDistinct": false, + "columns": [ + "phone" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_notes": { + "name": "review_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_pro_idx": { + "name": "credentials_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_review_idx": { + "name": "credentials_review_idx", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_expiry_idx": { + "name": "credentials_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credentials_pro_id_pro_profiles_user_id_fk": { + "name": "credentials_pro_id_pro_profiles_user_id_fk", + "tableFrom": "credentials", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credentials_reviewed_by_users_id_fk": { + "name": "credentials_reviewed_by_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_availability": { + "name": "pro_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute": { + "name": "end_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_availability_pro_idx": { + "name": "pro_availability_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weekday", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_availability_pro_id_pro_profiles_user_id_fk": { + "name": "pro_availability_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_availability", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_categories": { + "name": "pro_categories", + "schema": "", + "columns": { + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_categories_category_idx": { + "name": "pro_categories_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_categories_pro_id_pro_profiles_user_id_fk": { + "name": "pro_categories_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_categories", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pro_categories_category_id_categories_id_fk": { + "name": "pro_categories_category_id_categories_id_fk", + "tableFrom": "pro_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pro_categories_pro_id_category_id_pk": { + "name": "pro_categories_pro_id_category_id_pk", + "columns": [ + "pro_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_media": { + "name": "pro_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'photo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_media_pro_idx": { + "name": "pro_media_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_media_pro_id_pro_profiles_user_id_fk": { + "name": "pro_media_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_media", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_profiles": { + "name": "pro_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hourly_rate_cents": { + "name": "hourly_rate_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_experience": { + "name": "years_experience", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_location": { + "name": "base_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "service_radius_m": { + "name": "service_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_accepting_jobs": { + "name": "is_accepting_jobs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_jobs": { + "name": "completed_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_rate": { + "name": "response_rate", + "type": "numeric(4, 3)", + "primaryKey": false, + "notNull": false + }, + "avg_response_minutes": { + "name": "avg_response_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_account_id": { + "name": "stripe_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payouts_enabled": { + "name": "stripe_payouts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_profiles_location_gist": { + "name": "pro_profiles_location_gist", + "columns": [ + { + "expression": "base_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "pro_profiles_deck_idx": { + "name": "pro_profiles_deck_idx", + "columns": [ + { + "expression": "verification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_accepting_jobs", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pro_profiles\".\"verification_status\" = 'verified' AND \"pro_profiles\".\"is_accepting_jobs\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_profiles_user_id_users_id_fk": { + "name": "pro_profiles_user_id_users_id_fk", + "tableFrom": "pro_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pro_profiles_stripe_account_id_unique": { + "name": "pro_profiles_stripe_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_sessions": { + "name": "verification_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'didit'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_sessions_pro_idx": { + "name": "verification_sessions_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_sessions_pro_id_pro_profiles_user_id_fk": { + "name": "verification_sessions_pro_id_pro_profiles_user_id_fk", + "tableFrom": "verification_sessions", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_sessions_external_id_unique": { + "name": "verification_sessions_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photos": { + "name": "photos", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "urgency": { + "name": "urgency", + "type": "urgency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "budget_min_cents": { + "name": "budget_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_max_cents": { + "name": "budget_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address_text": { + "name": "address_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_location_gist": { + "name": "jobs_location_gist", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "jobs_client_idx": { + "name": "jobs_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_open_idx": { + "name": "jobs_open_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_client_id_users_id_fk": { + "name": "jobs_client_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jobs_category_id_categories_id_fk": { + "name": "jobs_category_id_categories_id_fk", + "tableFrom": "jobs", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "matches_pro_idx": { + "name": "matches_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_client_idx": { + "name": "matches_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_job_idx": { + "name": "matches_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_request_id_requests_id_fk": { + "name": "matches_request_id_requests_id_fk", + "tableFrom": "matches", + "tableTo": "requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_job_id_jobs_id_fk": { + "name": "matches_job_id_jobs_id_fk", + "tableFrom": "matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_pro_id_pro_profiles_user_id_fk": { + "name": "matches_pro_id_pro_profiles_user_id_fk", + "tableFrom": "matches", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_client_id_users_id_fk": { + "name": "matches_client_id_users_id_fk", + "tableFrom": "matches", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "matches_request_id_unique": { + "name": "matches_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_pending_idx": { + "name": "requests_pending_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_job_idx": { + "name": "requests_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_job_id_jobs_id_fk": { + "name": "requests_job_id_jobs_id_fk", + "tableFrom": "requests", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "requests_pro_id_pro_profiles_user_id_fk": { + "name": "requests_pro_id_pro_profiles_user_id_fk", + "tableFrom": "requests", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_job_pro_unique": { + "name": "requests_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swipes": { + "name": "swipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "swipe_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swipes_job_idx": { + "name": "swipes_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "swipes_job_id_jobs_id_fk": { + "name": "swipes_job_id_jobs_id_fk", + "tableFrom": "swipes", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "swipes_pro_id_pro_profiles_user_id_fk": { + "name": "swipes_pro_id_pro_profiles_user_id_fk", + "tableFrom": "swipes", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swipes_job_pro_unique": { + "name": "swipes_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_match_idx": { + "name": "messages_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_unread_idx": { + "name": "messages_unread_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"read_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_match_id_matches_id_fk": { + "name": "messages_match_id_matches_id_fk", + "tableFrom": "messages", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookings": { + "name": "bookings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_id": { + "name": "quote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_start": { + "name": "scheduled_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scheduled_end": { + "name": "scheduled_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "pro_completed_at": { + "name": "pro_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_confirmed_at": { + "name": "client_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_by": { + "name": "cancelled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookings_match_idx": { + "name": "bookings_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_status_idx": { + "name": "bookings_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pro_completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_schedule_idx": { + "name": "bookings_schedule_idx", + "columns": [ + { + "expression": "scheduled_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_match_id_matches_id_fk": { + "name": "bookings_match_id_matches_id_fk", + "tableFrom": "bookings", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookings_quote_id_quotes_id_fk": { + "name": "bookings_quote_id_quotes_id_fk", + "tableFrom": "bookings", + "tableTo": "quotes", + "columnsFrom": [ + "quote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_cancelled_by_users_id_fk": { + "name": "bookings_cancelled_by_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "cancelled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_cents": { + "name": "platform_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_bps": { + "name": "platform_fee_bps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "refunded_cents": { + "name": "refunded_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eur'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payments_status_idx": { + "name": "payments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_intent_idx": { + "name": "payments_intent_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_booking_id_bookings_id_fk": { + "name": "payments_booking_id_bookings_id_fk", + "tableFrom": "payments", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_booking_id_unique": { + "name": "payments_booking_id_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id" + ] + }, + "payments_stripe_payment_intent_id_unique": { + "name": "payments_stripe_payment_intent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_payment_intent_id" + ] + }, + "payments_stripe_transfer_id_unique": { + "name": "payments_stripe_transfer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_transfer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_stripe_events": { + "name": "processed_stripe_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quotes": { + "name": "quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "quote_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hours_estimate": { + "name": "hours_estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quotes_match_idx": { + "name": "quotes_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quotes_match_id_matches_id_fk": { + "name": "quotes_match_id_matches_id_fk", + "tableFrom": "quotes", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_subject_idx": { + "name": "reviews_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_booking_id_bookings_id_fk": { + "name": "reviews_booking_id_bookings_id_fk", + "tableFrom": "reviews", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_author_id_users_id_fk": { + "name": "reviews_author_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_subject_id_users_id_fk": { + "name": "reviews_subject_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_booking_author_unique": { + "name": "reviews_booking_author_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id", + "author_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_requests": { + "name": "deletion_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_requests_user_id_users_id_fk": { + "name": "deletion_requests_user_id_users_id_fk", + "tableFrom": "deletion_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_change_requests": { + "name": "email_change_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_change_requests_user_id_users_id_fk": { + "name": "email_change_requests_user_id_users_id_fk", + "tableFrom": "email_change_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_change_requests_token_unique": { + "name": "email_change_requests_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sms_new_request": { + "name": "sms_new_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_booking_reminder": { + "name": "sms_booking_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_marketing": { + "name": "sms_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_receipts": { + "name": "email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_marketing": { + "name": "email_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "push_messages": { + "name": "push_messages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_requests": { + "name": "push_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.booking_status": { + "name": "booking_status", + "schema": "public", + "values": [ + "scheduled", + "in_progress", + "awaiting_confirmation", + "completed", + "cancelled", + "disputed" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "id", + "licence", + "insurance" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "open", + "matched", + "booked", + "completed", + "cancelled" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "photo", + "work_sample" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "held", + "released", + "refunded", + "partially_refunded", + "failed" + ] + }, + "public.quote_kind": { + "name": "quote_kind", + "schema": "public", + "values": [ + "fixed", + "hourly" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "sent", + "accepted", + "declined", + "withdrawn", + "expired" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.swipe_direction": { + "name": "swipe_direction", + "schema": "public", + "values": [ + "left", + "right" + ] + }, + "public.urgency": { + "name": "urgency", + "schema": "public", + "values": [ + "now", + "this_week", + "flexible" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "client", + "pro", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "draft", + "pending", + "verified", + "rejected", + "suspended" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..9b34275 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3159 @@ +{ + "id": "dada12f4-9224-45c9-8fed-02dbbf7f2bad", + "prevId": "c22f2308-57f8-4ed0-ad77-3f56092335a9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_issuer_account_unique": { + "name": "accounts_issuer_account_unique", + "nullsNotDistinct": false, + "columns": [ + "issuer", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_impersonated_by_users_id_fk": { + "name": "sessions_impersonated_by_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": false + }, + "location_text": { + "name": "location_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_radius_m": { + "name": "search_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_phone_idx": { + "name": "users_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_unique": { + "name": "users_phone_unique", + "nullsNotDistinct": false, + "columns": [ + "phone" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_notes": { + "name": "review_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_pro_idx": { + "name": "credentials_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_review_idx": { + "name": "credentials_review_idx", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_expiry_idx": { + "name": "credentials_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credentials_pro_id_pro_profiles_user_id_fk": { + "name": "credentials_pro_id_pro_profiles_user_id_fk", + "tableFrom": "credentials", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credentials_reviewed_by_users_id_fk": { + "name": "credentials_reviewed_by_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_availability": { + "name": "pro_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute": { + "name": "end_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_availability_pro_idx": { + "name": "pro_availability_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weekday", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_availability_pro_id_pro_profiles_user_id_fk": { + "name": "pro_availability_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_availability", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_categories": { + "name": "pro_categories", + "schema": "", + "columns": { + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_categories_category_idx": { + "name": "pro_categories_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_categories_pro_id_pro_profiles_user_id_fk": { + "name": "pro_categories_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_categories", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pro_categories_category_id_categories_id_fk": { + "name": "pro_categories_category_id_categories_id_fk", + "tableFrom": "pro_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pro_categories_pro_id_category_id_pk": { + "name": "pro_categories_pro_id_category_id_pk", + "columns": [ + "pro_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_media": { + "name": "pro_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'photo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_media_pro_idx": { + "name": "pro_media_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_media_pro_id_pro_profiles_user_id_fk": { + "name": "pro_media_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_media", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_profiles": { + "name": "pro_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hourly_rate_cents": { + "name": "hourly_rate_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_experience": { + "name": "years_experience", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_location": { + "name": "base_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "service_radius_m": { + "name": "service_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_accepting_jobs": { + "name": "is_accepting_jobs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_jobs": { + "name": "completed_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_rate": { + "name": "response_rate", + "type": "numeric(4, 3)", + "primaryKey": false, + "notNull": false + }, + "avg_response_minutes": { + "name": "avg_response_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_account_id": { + "name": "stripe_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payouts_enabled": { + "name": "stripe_payouts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_profiles_location_gist": { + "name": "pro_profiles_location_gist", + "columns": [ + { + "expression": "base_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "pro_profiles_deck_idx": { + "name": "pro_profiles_deck_idx", + "columns": [ + { + "expression": "verification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_accepting_jobs", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pro_profiles\".\"verification_status\" = 'verified' AND \"pro_profiles\".\"is_accepting_jobs\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_profiles_user_id_users_id_fk": { + "name": "pro_profiles_user_id_users_id_fk", + "tableFrom": "pro_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pro_profiles_stripe_account_id_unique": { + "name": "pro_profiles_stripe_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_sessions": { + "name": "verification_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'didit'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_sessions_pro_idx": { + "name": "verification_sessions_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_sessions_pro_id_pro_profiles_user_id_fk": { + "name": "verification_sessions_pro_id_pro_profiles_user_id_fk", + "tableFrom": "verification_sessions", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_sessions_external_id_unique": { + "name": "verification_sessions_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photos": { + "name": "photos", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "urgency": { + "name": "urgency", + "type": "urgency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "budget_min_cents": { + "name": "budget_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_max_cents": { + "name": "budget_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address_text": { + "name": "address_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_location_gist": { + "name": "jobs_location_gist", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "jobs_client_idx": { + "name": "jobs_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_open_idx": { + "name": "jobs_open_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_client_id_users_id_fk": { + "name": "jobs_client_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jobs_category_id_categories_id_fk": { + "name": "jobs_category_id_categories_id_fk", + "tableFrom": "jobs", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "matches_pro_idx": { + "name": "matches_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_client_idx": { + "name": "matches_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_job_idx": { + "name": "matches_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_request_id_requests_id_fk": { + "name": "matches_request_id_requests_id_fk", + "tableFrom": "matches", + "tableTo": "requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_job_id_jobs_id_fk": { + "name": "matches_job_id_jobs_id_fk", + "tableFrom": "matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_pro_id_pro_profiles_user_id_fk": { + "name": "matches_pro_id_pro_profiles_user_id_fk", + "tableFrom": "matches", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_client_id_users_id_fk": { + "name": "matches_client_id_users_id_fk", + "tableFrom": "matches", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "matches_request_id_unique": { + "name": "matches_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_pending_idx": { + "name": "requests_pending_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_job_idx": { + "name": "requests_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_job_id_jobs_id_fk": { + "name": "requests_job_id_jobs_id_fk", + "tableFrom": "requests", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "requests_pro_id_pro_profiles_user_id_fk": { + "name": "requests_pro_id_pro_profiles_user_id_fk", + "tableFrom": "requests", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_job_pro_unique": { + "name": "requests_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swipes": { + "name": "swipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "swipe_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swipes_job_idx": { + "name": "swipes_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "swipes_job_id_jobs_id_fk": { + "name": "swipes_job_id_jobs_id_fk", + "tableFrom": "swipes", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "swipes_pro_id_pro_profiles_user_id_fk": { + "name": "swipes_pro_id_pro_profiles_user_id_fk", + "tableFrom": "swipes", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swipes_job_pro_unique": { + "name": "swipes_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_match_idx": { + "name": "messages_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_unread_idx": { + "name": "messages_unread_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"read_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_match_id_matches_id_fk": { + "name": "messages_match_id_matches_id_fk", + "tableFrom": "messages", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookings": { + "name": "bookings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_id": { + "name": "quote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_start": { + "name": "scheduled_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scheduled_end": { + "name": "scheduled_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "pro_completed_at": { + "name": "pro_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_confirmed_at": { + "name": "client_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_by": { + "name": "cancelled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookings_match_idx": { + "name": "bookings_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_status_idx": { + "name": "bookings_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pro_completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_schedule_idx": { + "name": "bookings_schedule_idx", + "columns": [ + { + "expression": "scheduled_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_match_id_matches_id_fk": { + "name": "bookings_match_id_matches_id_fk", + "tableFrom": "bookings", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookings_quote_id_quotes_id_fk": { + "name": "bookings_quote_id_quotes_id_fk", + "tableFrom": "bookings", + "tableTo": "quotes", + "columnsFrom": [ + "quote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_cancelled_by_users_id_fk": { + "name": "bookings_cancelled_by_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "cancelled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_cents": { + "name": "platform_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_bps": { + "name": "platform_fee_bps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "refunded_cents": { + "name": "refunded_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eur'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payments_status_idx": { + "name": "payments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_intent_idx": { + "name": "payments_intent_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_booking_id_bookings_id_fk": { + "name": "payments_booking_id_bookings_id_fk", + "tableFrom": "payments", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_booking_id_unique": { + "name": "payments_booking_id_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id" + ] + }, + "payments_stripe_payment_intent_id_unique": { + "name": "payments_stripe_payment_intent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_payment_intent_id" + ] + }, + "payments_stripe_transfer_id_unique": { + "name": "payments_stripe_transfer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_transfer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_stripe_events": { + "name": "processed_stripe_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quotes": { + "name": "quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "quote_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hours_estimate": { + "name": "hours_estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quotes_match_idx": { + "name": "quotes_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quotes_match_id_matches_id_fk": { + "name": "quotes_match_id_matches_id_fk", + "tableFrom": "quotes", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_subject_idx": { + "name": "reviews_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_booking_id_bookings_id_fk": { + "name": "reviews_booking_id_bookings_id_fk", + "tableFrom": "reviews", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_author_id_users_id_fk": { + "name": "reviews_author_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_subject_id_users_id_fk": { + "name": "reviews_subject_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_booking_author_unique": { + "name": "reviews_booking_author_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id", + "author_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_requests": { + "name": "deletion_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_requests_user_id_users_id_fk": { + "name": "deletion_requests_user_id_users_id_fk", + "tableFrom": "deletion_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_change_requests": { + "name": "email_change_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_change_requests_user_id_users_id_fk": { + "name": "email_change_requests_user_id_users_id_fk", + "tableFrom": "email_change_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_change_requests_token_unique": { + "name": "email_change_requests_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sms_new_request": { + "name": "sms_new_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_booking_reminder": { + "name": "sms_booking_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_marketing": { + "name": "sms_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_receipts": { + "name": "email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_marketing": { + "name": "email_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "push_messages": { + "name": "push_messages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_requests": { + "name": "push_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.booking_status": { + "name": "booking_status", + "schema": "public", + "values": [ + "scheduled", + "in_progress", + "awaiting_confirmation", + "completed", + "cancelled", + "disputed" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "id", + "licence", + "insurance" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "open", + "matched", + "booked", + "completed", + "cancelled" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "photo", + "work_sample" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "held", + "released", + "refunded", + "partially_refunded", + "failed" + ] + }, + "public.quote_kind": { + "name": "quote_kind", + "schema": "public", + "values": [ + "fixed", + "hourly" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "sent", + "accepted", + "declined", + "withdrawn", + "expired" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.swipe_direction": { + "name": "swipe_direction", + "schema": "public", + "values": [ + "left", + "right" + ] + }, + "public.urgency": { + "name": "urgency", + "schema": "public", + "values": [ + "now", + "this_week", + "flexible" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "client", + "pro", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "draft", + "pending", + "verified", + "rejected", + "suspended" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..1494b22 --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3166 @@ +{ + "id": "07652095-8ea9-4495-822e-ccd771b06f81", + "prevId": "dada12f4-9224-45c9-8fed-02dbbf7f2bad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_issuer_account_unique": { + "name": "accounts_issuer_account_unique", + "nullsNotDistinct": false, + "columns": [ + "issuer", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_impersonated_by_users_id_fk": { + "name": "sessions_impersonated_by_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": false + }, + "location_text": { + "name": "location_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_radius_m": { + "name": "search_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_role_idx": { + "name": "users_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_phone_idx": { + "name": "users_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_unique": { + "name": "users_phone_unique", + "nullsNotDistinct": false, + "columns": [ + "phone" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_notes": { + "name": "review_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_pro_idx": { + "name": "credentials_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_review_idx": { + "name": "credentials_review_idx", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credentials_expiry_idx": { + "name": "credentials_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credentials_pro_id_pro_profiles_user_id_fk": { + "name": "credentials_pro_id_pro_profiles_user_id_fk", + "tableFrom": "credentials", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credentials_reviewed_by_users_id_fk": { + "name": "credentials_reviewed_by_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_availability": { + "name": "pro_availability", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_minute": { + "name": "start_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute": { + "name": "end_minute", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_availability_pro_idx": { + "name": "pro_availability_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weekday", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_availability_pro_id_pro_profiles_user_id_fk": { + "name": "pro_availability_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_availability", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_categories": { + "name": "pro_categories", + "schema": "", + "columns": { + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pro_categories_category_idx": { + "name": "pro_categories_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_categories_pro_id_pro_profiles_user_id_fk": { + "name": "pro_categories_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_categories", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pro_categories_category_id_categories_id_fk": { + "name": "pro_categories_category_id_categories_id_fk", + "tableFrom": "pro_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pro_categories_pro_id_category_id_pk": { + "name": "pro_categories_pro_id_category_id_pk", + "columns": [ + "pro_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_media": { + "name": "pro_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "media_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'photo'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_media_pro_idx": { + "name": "pro_media_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_media_pro_id_pro_profiles_user_id_fk": { + "name": "pro_media_pro_id_pro_profiles_user_id_fk", + "tableFrom": "pro_media", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pro_profiles": { + "name": "pro_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hourly_rate_cents": { + "name": "hourly_rate_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "years_experience": { + "name": "years_experience", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "base_location": { + "name": "base_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "service_radius_m": { + "name": "service_radius_m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "skills": { + "name": "skills", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_accepting_jobs": { + "name": "is_accepting_jobs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rating_avg": { + "name": "rating_avg", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_jobs": { + "name": "completed_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_rate": { + "name": "response_rate", + "type": "numeric(4, 3)", + "primaryKey": false, + "notNull": false + }, + "avg_response_minutes": { + "name": "avg_response_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_account_id": { + "name": "stripe_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payouts_enabled": { + "name": "stripe_payouts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pro_profiles_location_gist": { + "name": "pro_profiles_location_gist", + "columns": [ + { + "expression": "base_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "pro_profiles_deck_idx": { + "name": "pro_profiles_deck_idx", + "columns": [ + { + "expression": "verification_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_accepting_jobs", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pro_profiles\".\"verification_status\" = 'verified' AND \"pro_profiles\".\"is_accepting_jobs\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pro_profiles_user_id_users_id_fk": { + "name": "pro_profiles_user_id_users_id_fk", + "tableFrom": "pro_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pro_profiles_stripe_account_id_unique": { + "name": "pro_profiles_stripe_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_sessions": { + "name": "verification_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'didit'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_sessions_pro_idx": { + "name": "verification_sessions_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_sessions_pro_id_pro_profiles_user_id_fk": { + "name": "verification_sessions_pro_id_pro_profiles_user_id_fk", + "tableFrom": "verification_sessions", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_sessions_external_id_unique": { + "name": "verification_sessions_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photos": { + "name": "photos", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "urgency": { + "name": "urgency", + "type": "urgency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'flexible'" + }, + "budget_min_cents": { + "name": "budget_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_max_cents": { + "name": "budget_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address_text": { + "name": "address_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_location_gist": { + "name": "jobs_location_gist", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "jobs_client_idx": { + "name": "jobs_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_open_idx": { + "name": "jobs_open_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_client_id_users_id_fk": { + "name": "jobs_client_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "jobs_category_id_categories_id_fk": { + "name": "jobs_category_id_categories_id_fk", + "tableFrom": "jobs", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "matches_pro_idx": { + "name": "matches_pro_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_client_idx": { + "name": "matches_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "matches_job_idx": { + "name": "matches_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_request_id_requests_id_fk": { + "name": "matches_request_id_requests_id_fk", + "tableFrom": "matches", + "tableTo": "requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_job_id_jobs_id_fk": { + "name": "matches_job_id_jobs_id_fk", + "tableFrom": "matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_pro_id_pro_profiles_user_id_fk": { + "name": "matches_pro_id_pro_profiles_user_id_fk", + "tableFrom": "matches", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_client_id_users_id_fk": { + "name": "matches_client_id_users_id_fk", + "tableFrom": "matches", + "tableTo": "users", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "matches_request_id_unique": { + "name": "matches_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.requests": { + "name": "requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "requests_pending_idx": { + "name": "requests_pending_idx", + "columns": [ + { + "expression": "pro_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_job_idx": { + "name": "requests_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_job_id_jobs_id_fk": { + "name": "requests_job_id_jobs_id_fk", + "tableFrom": "requests", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "requests_pro_id_pro_profiles_user_id_fk": { + "name": "requests_pro_id_pro_profiles_user_id_fk", + "tableFrom": "requests", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_job_pro_unique": { + "name": "requests_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.swipes": { + "name": "swipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pro_id": { + "name": "pro_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "swipe_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "swipes_job_idx": { + "name": "swipes_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "swipes_job_id_jobs_id_fk": { + "name": "swipes_job_id_jobs_id_fk", + "tableFrom": "swipes", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "swipes_pro_id_pro_profiles_user_id_fk": { + "name": "swipes_pro_id_pro_profiles_user_id_fk", + "tableFrom": "swipes", + "tableTo": "pro_profiles", + "columnsFrom": [ + "pro_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "swipes_job_pro_unique": { + "name": "swipes_job_pro_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id", + "pro_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_match_idx": { + "name": "messages_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_unread_idx": { + "name": "messages_unread_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"read_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_match_id_matches_id_fk": { + "name": "messages_match_id_matches_id_fk", + "tableFrom": "messages", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookings": { + "name": "bookings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quote_id": { + "name": "quote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_start": { + "name": "scheduled_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "scheduled_end": { + "name": "scheduled_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "booking_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "pro_completed_at": { + "name": "pro_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_confirmed_at": { + "name": "client_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_by": { + "name": "cancelled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bookings_match_idx": { + "name": "bookings_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_status_idx": { + "name": "bookings_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pro_completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookings_schedule_idx": { + "name": "bookings_schedule_idx", + "columns": [ + { + "expression": "scheduled_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookings_match_id_matches_id_fk": { + "name": "bookings_match_id_matches_id_fk", + "tableFrom": "bookings", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookings_quote_id_quotes_id_fk": { + "name": "bookings_quote_id_quotes_id_fk", + "tableFrom": "bookings", + "tableTo": "quotes", + "columnsFrom": [ + "quote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bookings_cancelled_by_users_id_fk": { + "name": "bookings_cancelled_by_users_id_fk", + "tableFrom": "bookings", + "tableTo": "users", + "columnsFrom": [ + "cancelled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_cents": { + "name": "platform_fee_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_fee_bps": { + "name": "platform_fee_bps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "refunded_cents": { + "name": "refunded_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eur'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payments_status_idx": { + "name": "payments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_intent_idx": { + "name": "payments_intent_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_booking_id_bookings_id_fk": { + "name": "payments_booking_id_bookings_id_fk", + "tableFrom": "payments", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "payments_booking_id_unique": { + "name": "payments_booking_id_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id" + ] + }, + "payments_stripe_payment_intent_id_unique": { + "name": "payments_stripe_payment_intent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_payment_intent_id" + ] + }, + "payments_stripe_transfer_id_unique": { + "name": "payments_stripe_transfer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_transfer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_stripe_events": { + "name": "processed_stripe_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quotes": { + "name": "quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "quote_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hours_estimate": { + "name": "hours_estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quotes_match_idx": { + "name": "quotes_match_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quotes_match_id_matches_id_fk": { + "name": "quotes_match_id_matches_id_fk", + "tableFrom": "quotes", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "booking_id": { + "name": "booking_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_subject_idx": { + "name": "reviews_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_booking_id_bookings_id_fk": { + "name": "reviews_booking_id_bookings_id_fk", + "tableFrom": "reviews", + "tableTo": "bookings", + "columnsFrom": [ + "booking_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_author_id_users_id_fk": { + "name": "reviews_author_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_subject_id_users_id_fk": { + "name": "reviews_subject_id_users_id_fk", + "tableFrom": "reviews", + "tableTo": "users", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_booking_author_unique": { + "name": "reviews_booking_author_unique", + "nullsNotDistinct": false, + "columns": [ + "booking_id", + "author_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + { + "expression": "entity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_idx": { + "name": "audit_log_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_requests": { + "name": "deletion_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actioned_at": { + "name": "actioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_requests_user_id_users_id_fk": { + "name": "deletion_requests_user_id_users_id_fk", + "tableFrom": "deletion_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_change_requests": { + "name": "email_change_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_change_requests_user_id_users_id_fk": { + "name": "email_change_requests_user_id_users_id_fk", + "tableFrom": "email_change_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_change_requests_token_unique": { + "name": "email_change_requests_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sms_new_request": { + "name": "sms_new_request", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_booking_reminder": { + "name": "sms_booking_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sms_marketing": { + "name": "sms_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_receipts": { + "name": "email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_marketing": { + "name": "email_marketing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "push_messages": { + "name": "push_messages", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "push_requests": { + "name": "push_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.booking_status": { + "name": "booking_status", + "schema": "public", + "values": [ + "scheduled", + "in_progress", + "awaiting_confirmation", + "completed", + "cancelled", + "disputed" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "id", + "licence", + "insurance" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "open", + "matched", + "booked", + "completed", + "cancelled" + ] + }, + "public.media_kind": { + "name": "media_kind", + "schema": "public", + "values": [ + "photo", + "work_sample" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "held", + "released", + "refunded", + "partially_refunded", + "failed" + ] + }, + "public.quote_kind": { + "name": "quote_kind", + "schema": "public", + "values": [ + "fixed", + "hourly" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "sent", + "accepted", + "declined", + "withdrawn", + "expired" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.swipe_direction": { + "name": "swipe_direction", + "schema": "public", + "values": [ + "left", + "right" + ] + }, + "public.urgency": { + "name": "urgency", + "schema": "public", + "values": [ + "now", + "this_week", + "flexible" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "client", + "pro", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "draft", + "pending", + "verified", + "rejected", + "suspended" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7f2d5f4..9caa809 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,34 @@ "when": 1787252153406, "tag": "0000_material_shadow_king", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787292806114, + "tag": "0001_bouncy_sally_floyd", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1787292942440, + "tag": "0002_jazzy_mac_gargan", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1787295054810, + "tag": "0003_amazing_turbo", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787296176203, + "tag": "0004_closed_nextwave", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/queries/deck.ts b/packages/db/src/queries/deck.ts index e7dabd1..94d78c3 100644 --- a/packages/db/src/queries/deck.ts +++ b/packages/db/src/queries/deck.ts @@ -176,11 +176,40 @@ export async function getDeck( */ export async function getShowcaseDeck( db: Db, - args: { lat: number; lng: number; limit?: number; now?: Date }, + args: { + lat: number; + lng: number; + categoryId?: string; + /** + * How far the person looking is willing to go, in metres. Applied ON TOP of + * each pro's own radius: both sides have to agree to the distance, and a pro + * who covers the whole city still should not fill the deck of someone who + * said "walking distance only". + */ + maxDistanceM?: number; + limit?: number; + now?: Date; + }, ): Promise { const limit = args.limit ?? DECK_PAGE_SIZE; const now = args.now ?? new Date(); + // Narrowing to one trade. Omitted means every trade, which is what the entry + // screen shows before the visitor has told us what they need. + const categoryFilter = args.categoryId + ? sql`AND EXISTS ( + SELECT 1 FROM pro_categories pc + WHERE pc.pro_id = p.user_id AND pc.category_id = ${args.categoryId} + )` + : sql``; + + // Same GiST index, same ST_DWithin — just measured against the searcher's + // limit rather than the pro's. + const distanceFilter = + args.maxDistanceM === undefined + ? sql`` + : sql`AND ST_DWithin(p.base_location, centre.g, ${args.maxDistanceM})`; + const rows = await db.execute<{ pro_id: string; name: string | null; @@ -240,6 +269,8 @@ export async function getShowcaseDeck( AND p.is_accepting_jobs = true AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now())) AND ST_DWithin(p.base_location, centre.g, p.service_radius_m) + ${distanceFilter} + ${categoryFilter} ORDER BY ST_Distance(p.base_location, centre.g) ASC LIMIT ${CANDIDATE_POOL} `); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index a857cde..e3115d4 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -2,12 +2,15 @@ import { relations } from 'drizzle-orm'; import { boolean, index, + integer, pgTable, text, timestamp, unique, uuid, } from 'drizzle-orm/pg-core'; +import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; +import { point } from '../postgis'; import { userRole } from './enums'; /** @@ -55,6 +58,23 @@ export const users = pgTable( banReason: text('ban_reason'), banExpires: timestamp('ban_expires', { withTimezone: true }), + /** + * Where this person is, and how far they are willing to look. + * + * This is the CLIENT-side answer only. A pro's working area is + * `pro_profiles.base_location` + `service_radius_m` — that pair is what the + * deck query and the verification review both read, so duplicating it here + * would give a pro two radii and no way to tell which one matched them to a + * job. Settings routes a pro's edit to the profile instead. + * + * Nullable because nobody is asked for it at signup: a null location means + * "we do not know yet", and callers fall back to the city centre. + */ + location: point('location'), + /** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */ + locationText: text('location_text'), + searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M), + /** Deck ranking penalises dormant pros, so this has to be maintained. */ lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 378512e..0d9a80f 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -7,3 +7,4 @@ export * from './messaging'; export * from './commerce'; export * from './reviews'; export * from './audit'; +export * from './settings'; diff --git a/packages/db/src/schema/pros.ts b/packages/db/src/schema/pros.ts index aac23c2..ace0a9c 100644 --- a/packages/db/src/schema/pros.ts +++ b/packages/db/src/schema/pros.ts @@ -37,6 +37,16 @@ export const proProfiles = pgTable( baseLocation: point('base_location').notNull(), serviceRadiusM: integer('service_radius_m').notNull().default(15000), + /** + * Free-text specialisms — "underfloor heating", "emergency callouts". + * + * Deliberately NOT the trade list: `pro_categories` is the matching key and + * is what verification checks a licence against, so it stays a closed set of + * rows. These are the pro's own words, for a customer to read, and nothing + * matches on them. + */ + skills: text('skills').array().notNull().default(sql`'{}'::text[]`), + verificationStatus: verificationStatus('verification_status').notNull().default('draft'), verifiedAt: timestamp('verified_at', { withTimezone: true }), suspendedReason: text('suspended_reason'), diff --git a/packages/db/src/schema/settings.ts b/packages/db/src/schema/settings.ts new file mode 100644 index 0000000..e9f3d40 --- /dev/null +++ b/packages/db/src/schema/settings.ts @@ -0,0 +1,93 @@ +import { relations } from 'drizzle-orm'; +import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { users } from './auth'; + +/** + * Per-user notification preferences. + * + * A missing row means "all defaults", so there is no backfill and no window + * where an existing user has no preferences. Every column therefore defaults to + * the value we would use in the absence of a row. + * + * NOTE: nothing consumes these yet — the worker that sends the messages arrives + * in M4. Until then this stores intent only, and the UI must say so rather than + * implying a toggle stops an SMS today. + */ +export const notificationPreferences = pgTable('notification_preferences', { + userId: uuid('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + + // SMS — the only channel that reaches a phone-only client, so the transactional + // ones default on and the marketing one defaults off. + smsNewRequest: boolean('sms_new_request').notNull().default(true), + smsBookingReminder: boolean('sms_booking_reminder').notNull().default(true), + smsMarketing: boolean('sms_marketing').notNull().default(false), + + // Email — only ever sent to a contactable address; see isSyntheticEmail. + emailReceipts: boolean('email_receipts').notNull().default(true), + emailMarketing: boolean('email_marketing').notNull().default(false), + + // Push — the native app does not exist yet; kept so the shape is stable. + pushMessages: boolean('push_messages').notNull().default(true), + pushRequests: boolean('push_requests').notNull().default(true), + + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * A GDPR erasure request. + * + * Deliberately a request rather than a cascade: bookings, payments and reviews + * carry foreign keys and statutory retention periods, so "delete my account" + * cannot simply DELETE the user. This records the ask and the promise; a human + * actions it. That is honest for a pre-launch product and becomes a liability + * the day there are real users, so a real flow must exist before launch. + */ +export const deletionRequests = pgTable('deletion_requests', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + reason: text('reason'), + /** Set when a human has completed the erasure. Null means outstanding. */ + actionedAt: timestamp('actioned_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({ + user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }), +})); + +export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({ + user: one(users, { fields: [deletionRequests.userId], references: [users.id] }), +})); + +/** + * A requested — but not yet proven — email address. + * + * This table exists to keep unverified addresses OUT of `users.email`. Writing + * them there directly (the previous behaviour) meant anyone could type a + * stranger's address and, because `users.email` is UNIQUE, permanently block + * that stranger from ever signing up with Google. It also turned the uniqueness + * error into an oracle for "is this address registered?". + * + * Nothing here is authoritative: the address only moves onto `users` once the + * token comes back. + */ +export const emailChangeRequests = pgTable('email_change_requests', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + /** Random, single-use. Compared in full; never rendered back to the client. */ + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + consumedAt: timestamp('consumed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const emailChangeRequestsRelations = relations(emailChangeRequests, ({ one }) => ({ + user: one(users, { fields: [emailChangeRequests.userId], references: [users.id] }), +})); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0946ceb..102ed63 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -39,7 +39,15 @@ function offset(lat: number, lng: number, metres: number, bearingDeg: number) { }; } +/** + * The trade taxonomy, in display order — the picker and the landing-page trade + * strip both render it top-to-bottom, so the order is the demand order: the + * trades a city marketplace sees most first, the long tail after. Slugs are the + * stable key (pros, jobs and tests reference them); names and icons are cosmetic. + * Icons are lucide names, kebab-case. + */ const CATEGORIES = [ + // Core trades — the eight that carry most of the volume. { slug: 'plumber', name: 'Plumber', icon: 'shower-head' }, { slug: 'electrician', name: 'Electrician', icon: 'zap' }, { slug: 'handyman', name: 'Handyman', icon: 'wrench' }, @@ -48,6 +56,60 @@ const CATEGORIES = [ { slug: 'locksmith', name: 'Locksmith', icon: 'key-round' }, { slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' }, { slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' }, + + // Home upkeep and renovation. + { slug: 'cleaner', name: 'House Cleaning', icon: 'sparkles' }, + { slug: 'gardener', name: 'Gardening & Landscaping', icon: 'sprout' }, + { slug: 'mover', name: 'Removals & Moving', icon: 'truck' }, + { slug: 'builder', name: 'Builder & Renovation', icon: 'brick-wall' }, + { slug: 'tiler', name: 'Tiling', icon: 'grid-2x2' }, + { slug: 'plasterer', name: 'Plastering & Drywall', icon: 'layers' }, + { slug: 'roofer', name: 'Roofing', icon: 'house' }, + { slug: 'flooring', name: 'Flooring & Parquet', icon: 'grid-3x3' }, + { slug: 'window-fitter', name: 'Windows & Glazing', icon: 'app-window' }, + { slug: 'blinds-curtains', name: 'Blinds & Curtains', icon: 'blinds' }, + { slug: 'kitchen-fitter', name: 'Kitchen Fitting', icon: 'cooking-pot' }, + { slug: 'bathroom-fitter', name: 'Bathroom Fitting', icon: 'bath' }, + { slug: 'gas-engineer', name: 'Gas & Boilers', icon: 'flame' }, + { slug: 'solar', name: 'Solar & Batteries', icon: 'sun' }, + { slug: 'drain-unblocking', name: 'Drains & Unblocking', icon: 'droplets' }, + { slug: 'pest-control', name: 'Pest Control', icon: 'bug' }, + { slug: 'waste-removal', name: 'Waste & Junk Removal', icon: 'trash-2' }, + { slug: 'window-cleaner', name: 'Window Cleaning', icon: 'spray-can' }, + { slug: 'pool-maintenance', name: 'Pool Maintenance', icon: 'waves' }, + { slug: 'upholstery', name: 'Upholstery & Furniture Repair', icon: 'sofa' }, + { slug: 'alarms-cctv', name: 'Alarms & CCTV', icon: 'cctv' }, + + // Devices and vehicles. + { slug: 'it-support', name: 'Computer & IT Support', icon: 'laptop' }, + { slug: 'phone-repair', name: 'Phone & Tablet Repair', icon: 'smartphone' }, + { slug: 'car-mechanic', name: 'Car Mechanic', icon: 'car' }, + { slug: 'car-detailing', name: 'Car Wash & Detailing', icon: 'car-front' }, + + // People care. + { slug: 'babysitter', name: 'Childcare & Nannies', icon: 'baby' }, + { slug: 'elderly-care', name: 'Elderly Care', icon: 'heart-handshake' }, + { slug: 'pet-care', name: 'Pet Care & Dog Walking', icon: 'dog' }, + { slug: 'massage', name: 'Massage & Physio', icon: 'hand-heart' }, + { slug: 'hairdresser', name: 'Hairdresser & Barber', icon: 'scissors' }, + { slug: 'beautician', name: 'Beauty & Nails', icon: 'gem' }, + { slug: 'personal-trainer', name: 'Personal Trainer', icon: 'dumbbell' }, + + // Lessons. + { slug: 'tutor', name: 'Private Tutor', icon: 'graduation-cap' }, + { slug: 'music-teacher', name: 'Music Lessons', icon: 'music' }, + + // Events and creative. + { slug: 'photographer', name: 'Photographer', icon: 'camera' }, + { slug: 'videographer', name: 'Video & Drone', icon: 'video' }, + { slug: 'dj', name: 'DJ & Live Music', icon: 'disc-3' }, + { slug: 'catering', name: 'Catering & Private Chefs', icon: 'chef-hat' }, + { slug: 'event-planner', name: 'Events & Parties', icon: 'party-popper' }, + + // Professional services. + { slug: 'accountant', name: 'Accounting & Tax', icon: 'calculator' }, + { slug: 'lawyer', name: 'Legal Services', icon: 'scale' }, + { slug: 'architect', name: 'Architect & Surveyor', icon: 'drafting-compass' }, ]; interface SeedPro { @@ -189,11 +251,17 @@ async function main() { await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId }); const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-')); + // The first photo is the deck card, so it has to be a FACE. picsum returns + // landscape stock scenery — a locksmith on a railway track — which makes the + // deck unreadable as a people-picker no matter what the layout does. + // pravatar serves ~70 portraits; i is 0-based and img is 1-based. + const portrait = (i % 70) + 1; await db.insert(schema.proMedia).values([ - { proId: user.id, url: `https://picsum.photos/seed/${slug}-1/800/1000`, position: 0 }, + { proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 }, { + // The second slot is genuinely for work: scenery is fine here. proId: user.id, - url: `https://picsum.photos/seed/${slug}-2/800/1000`, + url: `https://picsum.photos/seed/${slug}-work/800/1000`, kind: 'work_sample' as const, position: 1, }, diff --git a/packages/db/test/showcase.test.ts b/packages/db/test/showcase.test.ts index f5ad3ba..83f5e9d 100644 --- a/packages/db/test/showcase.test.ts +++ b/packages/db/test/showcase.test.ts @@ -10,6 +10,7 @@ * three pros specifically to prove each exclusion reason fires. */ import { config } from 'dotenv'; +import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); @@ -61,6 +62,48 @@ describe('getShowcaseDeck', () => { expect(three).toHaveLength(3); }); + it('honours the searcher own range, not just the pro one', async () => { + // Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel + // here happily, but someone who said "within 3 km" did not ask for her. + const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 }); + const nearNames = near.map((c) => c.name); + + expect(nearNames).not.toContain('Marta Vidal'); + expect(nearNames).toContain('Marc Oliveras'); // 800 m away + expect(near.every((c) => c.distanceM <= 3_000)).toBe(true); + }); + + it('narrows to a single trade when given a category', async () => { + const [plumber] = await db.execute<{ id: string }>( + sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, + ); + if (!plumber) throw new Error('plumber category missing from seed'); + + const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 }); + + expect(cards.length).toBeGreaterThan(0); + // Every card carries its trade names, so the filter is checkable per card. + expect(cards.every((c) => c.categories.includes('Plumber'))).toBe(true); + // And it must be a strict subset — otherwise the filter did nothing. + expect(cards.length).toBeLessThan(names.length); + }); + + it('still excludes the ineligible when a category is given', async () => { + const [plumber] = await db.execute<{ id: string }>( + sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, + ); + if (!plumber) throw new Error('plumber category missing from seed'); + + const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 }); + const filtered = cards.map((c) => c.name); + + // Pau Ribas is a plumber — he is kept out by radius, not by trade, so this + // proves the category filter did not replace the eligibility rules. + expect(filtered).not.toContain('Pau Ribas'); + expect(filtered).not.toContain('Unverified Ulla'); + expect(filtered).not.toContain('Away Arnau'); + }); + it('never returns a card with a distance beyond that pro’s own radius', async () => { const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 }); // The card shape does not expose serviceRadiusM, but ST_DWithin is the only diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 36b262a..8afc2d8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,6 +29,10 @@ export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15% export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling +/** Free-text specialisms on a pro profile. A card nobody can read is worse than a short one. */ +export const MAX_SKILLS = 12; +export const MAX_SKILL_LENGTH = 40; + /** Pro service radius bounds, metres. */ export const MIN_SERVICE_RADIUS_M = 1_000; export const MAX_SERVICE_RADIUS_M = 50_000; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 4b47e19..cdfa0c5 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -2,6 +2,8 @@ import { z } from 'zod'; import { MAX_QUOTE_CENTS, MAX_SERVICE_RADIUS_M, + MAX_SKILL_LENGTH, + MAX_SKILLS, MIN_QUOTE_CENTS, MIN_SERVICE_RADIUS_M, } from './constants'; @@ -55,6 +57,51 @@ export const proProfileSchema = z.object({ }); export type ProProfileInput = z.infer; +/** + * "Where I am, and how far I will go." + * + * Every field is optional so the settings screen can save a moved pin without + * touching the radius, but an empty object is rejected — a mutation that + * silently does nothing is indistinguishable from one that failed. + * + * `addressText` is a label a human typed, never geocoded: matching happens on + * the coordinates alone, so an empty or wrong label costs nothing but clarity. + */ +export const updateLocationSchema = z + .object({ + location: latLngSchema.optional(), + addressText: z.string().trim().max(255).optional(), + radiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M).optional(), + }) + .refine((v) => Object.values(v).some((field) => field !== undefined), { + message: 'Nothing to update', + }); +export type UpdateLocationInput = z.infer; + +/** + * A pro's own words for what they are good at. + * + * Deduplicated case-insensitively and server-side, keeping the first spelling: + * "Boiler repair" and "boiler repair" are one skill to a reader, and letting + * both through is how a card ends up padded with the same claim twice. Doing it + * here rather than in the form means it holds for every caller. + */ +export const updateSkillsSchema = z.object({ + skills: z + .array(z.string().trim().min(2).max(MAX_SKILL_LENGTH)) + .max(MAX_SKILLS) + .transform((values) => { + const seen = new Set(); + return values.filter((value) => { + const key = value.toLocaleLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }), +}); +export type UpdateSkillsInput = z.input; + export const swipeSchema = z.object({ jobId: z.string().uuid(), proId: z.string().uuid(),