diff --git a/.env.example b/.env.example index 6e311b7..cf1374a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # ---- Database (Postgres 16 + PostGIS) ---- DATABASE_URL=postgresql://linkder:linkder@localhost:5442/linkder +# Managed Postgres only. Verifies the server's IDENTITY, not merely that the +# link is encrypted — sslmode=require alone leaves you open to anything that can +# answer for the hostname. Takes a path to the provider's .crt, or the PEM +# inline for a platform whose secrets are environment variables. +DATABASE_CA_CERT= # ---- Redis (pub/sub for SSE chat + BullMQ queues) ---- REDIS_URL=redis://localhost:6389 @@ -46,7 +51,7 @@ AUTH_MICROSOFT_TENANT_ID=common # GitHub only returns a primary email if the OAuth app requests `user:email` # AND the account has a verified one; a user whose email is private signs up # with no address, so never assume `users.email` is reachable mail — gate -# outbound on isSyntheticEmail() from @linkder/shared, same as phone signups. +# outbound on isSyntheticEmail() from @linkdr/shared, same as phone signups. AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= @@ -63,7 +68,7 @@ AUTH_GITHUB_SECRET= MAPBOX_TOKEN= # ISO 3166-1 alpha-2. Bounds results to one country: "Carrer de Sants" matches # in several places and the wrong continent is a worse answer than none. -MAPBOX_COUNTRY=es +MAPBOX_COUNTRY=mx # ---- Phone OTP (Twilio Verify) ---- TWILIO_ACCOUNT_SID= @@ -82,21 +87,14 @@ DIDIT_API_KEY= DIDIT_WORKFLOW_ID= DIDIT_WEBHOOK_SECRET= -# ---- Cloudflare R2 (S3-compatible object storage) ---- -R2_ACCOUNT_ID= -R2_ACCESS_KEY_ID= -R2_SECRET_ACCESS_KEY= -R2_BUCKET=linkder-uploads -R2_PUBLIC_URL= - # ---- Resend (transactional email) ---- RESEND_API_KEY= -EMAIL_FROM=noreply@linkder.app +EMAIL_FROM=noreply@linkdr.app # ---- Launch market (city-scoped MVP) ---- -NEXT_PUBLIC_CITY_NAME=Barcelona -NEXT_PUBLIC_CITY_LAT=41.3874 -NEXT_PUBLIC_CITY_LNG=2.1686 +NEXT_PUBLIC_CITY_NAME=Ciudad de México +NEXT_PUBLIC_CITY_LAT=19.4326 +NEXT_PUBLIC_CITY_LNG=-99.1332 TWILIO_FROM_NUMBER= # Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production. @@ -105,3 +103,10 @@ ALLOW_DEV_LOGIN=false # Bugsink (Sentry-compatible error tracking). Write-only ingest key, safe in the # client bundle. Leave blank to disable reporting entirely. NEXT_PUBLIC_SENTRY_DSN= + +# ---- Object storage (DigitalOcean Spaces) ---- +SPACES_REGION=nyc3 +SPACES_BUCKET= +SPACES_KEY= +SPACES_SECRET= +SPACES_CDN_URL= diff --git a/DESIGN.md b/DESIGN.md index c35be9b..f9fcc2c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,8 +1,8 @@ -# Linkder Design System +# Linkdr Design System -**Version 1.0** · derived from the wix.com design language, adapted for Linkder. +**Version 1.0** · derived from the wix.com design language, adapted for Linkdr. -This document is the single source of truth for how Linkder looks. Every screen must be +This document is the single source of truth for how Linkdr looks. Every screen must be buildable from the tokens and components below. If a screen needs something that is not in here, add it here first, then build it. @@ -15,7 +15,7 @@ whitespace rhythm. Values below were read off the live stylesheet, not eyeballed Adopted: typefaces (Wix Madefor Display / Text, published under the SIL Open Font License via Google Fonts), colour ramps, radius and spacing conventions, component geometry. -**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkder is +**Not** adopted: Wix's logo, wordmark, product names, illustrations or photography. Linkdr is not affiliated with Wix and must never present itself as such. --- @@ -153,7 +153,7 @@ Display sizes use `clamp()` so one token works from 360px to desktop. ## 4. Space and layout -**Linkder is a mobile-only product.** There is no desktop layout, no marketing site and no +**Linkdr is a mobile-only product.** There is no desktop layout, no marketing site and no responsive breakpoint work. Design for a 360–430px viewport and nothing else. On a wider screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for someone who opened it on a laptop, not a layout to design for. diff --git a/README.md b/README.md index ba6fc11..08ddf0a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Linkder +# Linkdr Swipe-to-hire marketplace for local professional services. A client describes a job once, then swipes through **verified** local pros — plumbers, electricians, handymen. A right swipe sends the @@ -61,8 +61,8 @@ packages/ui shared components (M1) ```bash pnpm test # everything -pnpm --filter @linkder/shared test # 46 unit tests, no database needed -pnpm --filter @linkder/db test # 18 integration tests, needs a seeded database +pnpm --filter @linkdr/shared test # 46 unit tests, no database needed +pnpm --filter @linkdr/db test # 18 integration tests, needs a seeded database ``` The seed is deterministic: every pro sits at a **known** distance and bearing from the city centre, diff --git a/SECURITY-FINDINGS.md b/SECURITY-FINDINGS.md index 9555889..51ff5c7 100644 --- a/SECURITY-FINDINGS.md +++ b/SECURITY-FINDINGS.md @@ -11,7 +11,7 @@ so nobody re-reports them; the rest are open and are the M1 exit criteria. | 1 | `pro.upsertProfile` computed `requiresReReview` and never applied it — a verified plumber could silently become a verified electrician 40 km away | Demotes a `verified` profile to `pending` in the same transaction, writes an audit row, and now counts a **trade change** as material (it did not before). Needed a new `verified -> pending` edge in `VERIFICATION_GRAPH` | | 2 | `phoneNumber()` registers `/phone-number/request-password-reset`, `/phone-number/reset-password` and `/sign-in/phone-number` **unconditionally** — not gated on `emailAndPassword.enabled: false`. Together they mint a password credential with no SMS sent to the owner, then accept it forever with no OTP | All three served as 404 via `disabledPaths` (checked in `onRequest`, before rate limiting) | | 3 | `bearer()` accepted the raw plaintext `sessions.token` column as an `Authorization: Bearer` credential — one leaked DB row is a replayable login | Plugin removed. The mobile client it was for does not exist yet | -| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkder/shared` | +| 4 | Phone numbers stored exactly as typed, so `+34600111222` and `0034600111222` are two "unique" accounts — defeating the UNIQUE constraint, bans, and duplicate detection | `phoneNumberValidator` pins E.164 on send-otp and sign-in; `toE164`/`isE164` added to `@linkdr/shared` | | 5 | `NEXT_PUBLIC_APP_URL` fell back to `http://localhost:3000`, which drops `Secure` and the `__Secure-` prefix from the production session cookie | Throws at boot in production | ## Open — must close before M1 ships diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a098e94..e4cbb09 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -8,7 +8,7 @@ loadEnv({ path: '../../.env' }); const config: NextConfig = { reactStrictMode: true, // The workspace packages ship TypeScript source, not build output. - transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'], + transpilePackages: ['@linkdr/api', '@linkdr/db', '@linkdr/shared', '@linkdr/storage'], images: { remotePatterns: [ // Seed data only — real pros upload to R2. The deck renders a plain , diff --git a/apps/web/package.json b/apps/web/package.json index 81af4e5..2473bc9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/web", + "name": "@linkdr/web", "version": "0.0.0", "private": true, "type": "module", @@ -12,11 +12,11 @@ "test": "vitest run" }, "dependencies": { - "@linkder/api": "workspace:*", - "@linkder/db": "workspace:*", - "@linkder/notify": "workspace:*", - "@linkder/shared": "workspace:*", - "@linkder/storage": "workspace:*", + "@linkdr/api": "workspace:*", + "@linkdr/db": "workspace:*", + "@linkdr/notify": "workspace:*", + "@linkdr/shared": "workspace:*", + "@linkdr/storage": "workspace:*", "@sentry/nextjs": "^10.70.0", "@tanstack/react-query": "^5.62.0", "@trpc/client": "^11.18.0", diff --git a/apps/web/src/app/admin/[proId]/page.tsx b/apps/web/src/app/admin/[proId]/page.tsx index 79790fd..ab48e72 100644 --- a/apps/web/src/app/admin/[proId]/page.tsx +++ b/apps/web/src/app/admin/[proId]/page.tsx @@ -104,7 +104,7 @@ export default async function AdminProPage({ params }: { params: Promise<{ proId
- +
- Linkder admin + Linkdr admin Signed in as {me.name ?? me.email ?? 'admin'} diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 7078608..732500b 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/nextjs'; import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { appRouter, createContext } from '@linkder/api'; -import { db } from '@linkder/db'; +import { appRouter, createContext } from '@linkdr/api'; +import { db } from '@linkdr/db'; import { resolveSession } from '@/server/session'; /** diff --git a/apps/web/src/app/deck/[jobId]/deck-client.tsx b/apps/web/src/app/deck/[jobId]/deck-client.tsx index 4ec3085..0835551 100644 --- a/apps/web/src/app/deck/[jobId]/deck-client.tsx +++ b/apps/web/src/app/deck/[jobId]/deck-client.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useState } from 'react'; -import type { DeckCard } from '@linkder/db'; +import type { DeckCard } from '@linkdr/db'; import { Deck } from '@/components/deck'; import { api } from '@/lib/trpc'; diff --git a/apps/web/src/app/jobs/new/form.tsx b/apps/web/src/app/jobs/new/form.tsx index 4472ec9..2429cf8 100644 --- a/apps/web/src/app/jobs/new/form.tsx +++ b/apps/web/src/app/jobs/new/form.tsx @@ -182,14 +182,14 @@ export function NewJobForm({
- + setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))} inputMode="decimal" /> - + setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7e2f389..5bd77be 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ 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 { ProjectPanel } from '@/components/chrome/project-panel'; import '@/styles/globals.css'; /** @@ -24,12 +25,12 @@ const text = Wix_Madefor_Text({ export const metadata: Metadata = { title: { - default: 'Linkder — hire a verified local pro', - template: '%s · Linkder', + default: 'Linkdr — hire a verified local pro', + template: '%s · Linkdr', }, description: 'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.', - appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkder' }, + appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkdr' }, }; export const viewport: Viewport = { @@ -57,12 +58,32 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* + Two columns: the product on the left, what it is on the right. + 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. + inside the screen — sign-in, onboarding and the job form included. + That is also what lets somebody use the WHOLE app inside the left + column without ever leaving it, while the panel beside it stays put. + + Below `lg` the panel drops underneath rather than squashing beside, + and on a handset PhoneFrame collapses its bezel so the app simply + fills the viewport with the panel below the fold. */} -
- {children} +
+
+ {children} +
+ +
+ +
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 09b0298..55a3762 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'; * The entry screen. * * Not a marketing page. This is the product itself, running: a phone with a - * live, draggable deck of real verified pros inside it. Linkder's whole promise + * live, draggable deck of real verified pros inside it. Linkdr's whole promise * is a gesture, and a paragraph describing a gesture is worth nothing next to * being able to do it. * diff --git a/apps/web/src/app/pro/onboarding/wizard.tsx b/apps/web/src/app/pro/onboarding/wizard.tsx index 2327f4b..0a255b4 100644 --- a/apps/web/src/app/pro/onboarding/wizard.tsx +++ b/apps/web/src/app/pro/onboarding/wizard.tsx @@ -9,7 +9,7 @@ import { DEFAULT_SERVICE_RADIUS_M, MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { cn } from '@/lib/utils'; import { uploadFile } from '@/lib/upload'; import { @@ -167,7 +167,7 @@ export function OnboardingWizard({ {step === 1 && (
- + setHeadline(e.target.value)} @@ -186,7 +186,7 @@ export function OnboardingWizard({
- + setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))} diff --git a/apps/web/src/app/pro/page.tsx b/apps/web/src/app/pro/page.tsx index f126705..55d7a0e 100644 --- a/apps/web/src/app/pro/page.tsx +++ b/apps/web/src/app/pro/page.tsx @@ -64,7 +64,7 @@ export default async function ProHomePage() { } /> - +
); diff --git a/apps/web/src/app/profile-panel.tsx b/apps/web/src/app/profile-panel.tsx index 8895475..6bbb8ba 100644 --- a/apps/web/src/app/profile-panel.tsx +++ b/apps/web/src/app/profile-panel.tsx @@ -176,7 +176,7 @@ function ProProfile() { value={String(p.media.length)} /> - + void }) { const me = api.user.me.useQuery(undefined, { retry: false }); if (me.isLoading) { - return
; + return ( + + + + ); } if (me.error || !me.data) { return ( @@ -28,22 +33,37 @@ export function SettingsPanel() { ); } - return ; + return ; } function PanelShell({ children }: { children: React.ReactNode }) { return ( -
+

Settings

{children}
); } +/** + * §6.11. Shaped like what it is waiting for — an identity card and two groups — + * rather than one grey slab, so the layout does not jump when the data lands. + */ +function LoadingSkeleton() { + return ( +
+
+
+
+
+ ); +} + type Me = RouterOutputs['user']['me']; -function SignedIn({ me }: { me: Me }) { +function SignedIn({ me, onNavigate }: { me: Me; onNavigate?: (tab: PhoneTab) => void }) { const utils = api.useUtils(); + const toast = useToast(); const prefs = api.notification.get.useQuery(); const updatePrefs = api.notification.update.useMutation({ onMutate: async (next) => { @@ -55,34 +75,21 @@ function SignedIn({ me }: { me: Me }) { }, onError: (_e, _next, context) => { if (context?.previous) utils.notification.get.setData(undefined, context.previous); + // Without this the switch just slides back under the thumb, which reads as + // the tap not registering rather than as the save failing. + toast('That did not save. Check your connection and try again.', { tone: 'error' }); }, 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. - */} - - - + @@ -119,8 +126,21 @@ function SignedIn({ me }: { me: Me }) { {isPro && ( - - {}} /> + + {/* + The chevron is conditional on the callback because the tab is owned + by the shell above this panel. Rendered without one — in a test, or + anywhere this panel is mounted alone — it stays a plain fact instead + of a button that goes nowhere. + */} + onNavigate('profile') : undefined} + /> )} @@ -139,8 +159,13 @@ function SignedIn({ me }: { me: Me }) { - {}} /> - {}} /> + {/* + No onClick, so no chevron. These two documents do not exist yet, and a + row that opens nothing is worse than a row that says so — it is + indistinguishable from a link that is broken. + */} + + - { - requestDeletion.mutate({}, { onSuccess: () => setDeletionAsked(true) }); - }} - /> - + ); } diff --git a/apps/web/src/app/showcase-deck.tsx b/apps/web/src/app/showcase-deck.tsx index f21b404..e22bfd2 100644 --- a/apps/web/src/app/showcase-deck.tsx +++ b/apps/web/src/app/showcase-deck.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { X } from 'lucide-react'; -import type { DeckCard } from '@linkder/db'; +import type { DeckCard } from '@linkdr/db'; import { Deck, type SwipeVerdict } from '@/components/deck'; import { Chip, ScrollStrip } from '@/components/ui'; import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs'; @@ -167,7 +167,7 @@ export function ShowcaseDeck({ return (
{tab === 'settings' ? ( - + ) : tab === 'profile' ? ( ) : tab === 'search' ? ( @@ -251,7 +251,16 @@ export function ShowcaseDeck({ {/* Inside the phone frame, not the page — the sheet belongs to this screen and must not cover the browser chrome around the mock. */} - + { + setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } }); + setTab('jobs'); + }} + /> -

Linkder

+

Linkdr

Sign in

We will text you a 6-digit code. No password to forget. 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 1a32c8a..6b1203a 100644 --- a/apps/web/src/app/sign-in/sign-in-form.tsx +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -69,7 +69,7 @@ export function SignInForm() { autoComplete="tel" inputMode="tel" required - placeholder="+34 600 123 456" + placeholder="+52 55 1234 5678" value={phone} onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))} /> diff --git a/apps/web/src/components/chrome/project-panel.tsx b/apps/web/src/components/chrome/project-panel.tsx new file mode 100644 index 0000000..519f21c --- /dev/null +++ b/apps/web/src/components/chrome/project-panel.tsx @@ -0,0 +1,636 @@ +'use client'; + +import { useState } from 'react'; +import { + BadgeCheck, + CalendarCheck, + Check, + Copy, + MapPin, + MessagesSquare, + Star, + Zap, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * The right-hand column: what the app in the phone beside it actually is. + * + * Static on purpose. The client reads this while USING the product in the left + * column, so nothing here navigates, nothing here is a screenshot, and nothing + * here moves when they swipe. + * + * Content lives in the arrays below rather than in the markup, so adding a + * feature or swapping a dependency is one line and the layout is untouched. + * + * Both languages live in the SAME entry rather than in two parallel documents — + * a decision and its `today` line have to move together, and the fastest way to + * end up with a Spanish half-truth is to let two copies of this file drift. + */ + +type Lang = 'en' | 'es'; + +/** One string in both languages. Everything the client reads is one of these. */ +type Copy = { en: string; es: string }; + +const LANGS: { code: Lang; label: string }[] = [ + { code: 'en', label: 'English' }, + { code: 'es', label: 'Español' }, +]; + +const DEMO = { phone: '+525500000000', code: '000000' }; + +const UI = { + language: { en: 'Language', es: 'Idioma' }, + copy: { en: 'Copy', es: 'Copiar' }, + copied: { en: 'copied', es: 'copiado' }, + today: { en: 'Today: ', es: 'Hoy: ' }, +} satisfies Record; + +const INTRO = { + title: { + en: 'Hire a tradesperson the way you swipe', + es: 'Contrata a un profesional deslizando', + }, + body: { + en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your street, agree a price in chat, book the slot and review each other afterwards.', + es: 'Un marketplace móvil que conecta a clientes con profesionales locales verificados. Publica un trabajo, desliza entre los profesionales que cubren tu calle, acuerda un precio en el chat, reserva la cita y valoraos después.', + }, +} satisfies Record; + +const TRY = { + heading: { en: 'Try it yourself', es: 'Pruébalo tú mismo' }, + body: { + en: 'Sign in on the phone to the left. The whole product runs in there.', + es: 'Inicia sesión en el móvil de al lado. El producto entero funciona ahí dentro.', + }, + phone: { en: 'Mobile', es: 'Móvil' }, + code: { en: 'Code', es: 'Código' }, +} satisfies Record; + +const STACK: { group: Copy; items: string[] }[] = [ + { + group: { en: 'App', es: 'App' }, + items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'], + }, + { group: { en: 'API', es: 'API' }, items: ['tRPC v11', 'Zod', 'better-auth'] }, + { + group: { en: 'Data', es: 'Datos' }, + items: ['DO Managed Postgres', 'PostGIS', 'Drizzle ORM', 'DO Managed Redis'], + }, + { + group: { en: 'Services', es: 'Servicios' }, + items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'], + }, + { group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] }, +]; + +const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [ + { + icon: Zap, + title: { en: 'Swipe to hire', es: 'Desliza para contratar' }, + body: { en: 'Send a job with one gesture.', es: 'Envía un trabajo con un solo gesto.' }, + }, + { + icon: MapPin, + title: { en: 'Real distance', es: 'Distancia real' }, + body: { + en: 'PostGIS ranks by metres, not postcodes.', + es: 'PostGIS ordena por metros, no por códigos postales.', + }, + }, + { + icon: BadgeCheck, + title: { en: 'Verified pros', es: 'Profesionales verificados' }, + body: { + en: 'ID, insurance and licence checked first.', + es: 'Identidad, seguro y licencia comprobados antes de entrar.', + }, + }, + { + icon: MessagesSquare, + title: { en: 'Chat per job', es: 'Un chat por trabajo' }, + body: { en: 'Private, with photos and receipts.', es: 'Privado, con fotos y recibos.' }, + }, + { + icon: CalendarCheck, + title: { en: 'Quote to booking', es: 'Del presupuesto a la reserva' }, + body: { + en: 'Agree a price, book the slot, confirm.', + es: 'Acordáis un precio, se reserva la cita y se confirma.', + }, + }, + { + icon: Star, + title: { en: 'Blind reviews', es: 'Valoraciones a ciegas' }, + body: { + en: 'Hidden until both sides have written.', + es: 'Ocultas hasta que ambas partes han escrito.', + }, + }, +]; + +/** + * The open questions, grouped. + * + * Every one of these has a CURRENT behaviour — nothing here is unbuilt because + * it was forgotten. `today` says what happens if nobody decides, which is the + * only honest way to present a decision: the client is confirming or changing + * something, not filling in a blank. + */ +const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ + { + group: { en: 'Money', es: 'Dinero' }, + items: [ + { + q: { + en: 'Do we hold the money until the job is done, or do customers pay the pro directly?', + es: '¿Retenemos el dinero hasta que el trabajo esté hecho, o el cliente paga directamente al profesional?', + }, + today: { + en: 'Nothing is charged. Quotes and bookings work; no payment is taken at any point.', + es: 'No se cobra nada. Los presupuestos y las reservas funcionan; no se cobra en ningún momento.', + }, + }, + { + q: { + en: 'What is the commission, and who pays it — the customer, the pro, or split?', + es: '¿Cuál es la comisión y quién la paga: el cliente, el profesional o a medias?', + }, + today: { + en: 'Set to 15% in config, applied nowhere.', + es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.', + }, + }, + { + q: { + en: 'Deposit up front, or the whole amount on completion?', + es: '¿Señal por adelantado o el importe completo al terminar?', + }, + today: { + en: 'Neither. The slot is booked on a promise.', + es: 'Ninguna de las dos. La cita se reserva con una promesa.', + }, + }, + { + q: { + en: 'A customer cancels the day before — what do they owe?', + es: 'Un cliente cancela el día antes: ¿qué debe pagar?', + }, + today: { + en: 'Free up to 24h before, then 25%. The rule is written and tested; no money moves.', + es: 'Gratis hasta 24 h antes, después el 25%. La regla está escrita y probada; no se mueve dinero.', + }, + }, + ], + }, + { + group: { en: 'Scheduling', es: 'Agenda' }, + items: [ + { + q: { + en: 'Do pros publish real availability, or is a time agreed in the chat?', + es: '¿Los profesionales publican disponibilidad real, o se acuerda la hora en el chat?', + }, + today: { + en: 'Agreed in chat. The customer picks any date and time when accepting a quote.', + es: 'Se acuerda en el chat. El cliente elige cualquier fecha y hora al aceptar un presupuesto.', + }, + }, + { + q: { + en: 'Should the system stop a pro being double-booked?', + es: '¿Debe el sistema impedir que un profesional tenga dos reservas a la vez?', + }, + today: { + en: 'No check. Two customers can book the same pro for the same hour.', + es: 'No hay ninguna comprobación. Dos clientes pueden reservar al mismo profesional a la misma hora.', + }, + }, + { + q: { + en: 'If a customer never confirms the work is finished, should it auto-confirm?', + es: 'Si el cliente nunca confirma que el trabajo está terminado, ¿debe confirmarse solo?', + }, + today: { + en: 'It waits forever. A 72-hour rule is written but nothing runs it.', + es: 'Espera para siempre. Hay una regla de 72 horas escrita, pero nada la ejecuta.', + }, + }, + ], + }, + { + group: { en: 'Trust and safety', es: 'Confianza y seguridad' }, + items: [ + { + q: { + en: 'Should we block phone numbers and emails in chat?', + es: '¿Bloqueamos teléfonos y correos en el chat?', + }, + today: { + en: 'Anything can be sent. Two people can agree to take the job off the platform.', + es: 'Se puede enviar cualquier cosa. Dos personas pueden acordar sacar el trabajo de la plataforma.', + }, + }, + { + q: { + en: 'What happens when the two sides disagree about finished work?', + es: '¿Qué pasa cuando las dos partes no se ponen de acuerdo sobre un trabajo terminado?', + }, + today: { + en: 'A disputed state exists in the model. Nothing can reach it.', + es: 'Existe un estado «en disputa» en el modelo. Nada puede llegar a él.', + }, + }, + { + q: { + en: 'ID checks — automated, or a person reviewing documents?', + es: 'Verificación de identidad: ¿automática o revisada por una persona?', + }, + today: { + en: 'A person. Documents are uploaded and reviewed by hand in the admin queue.', + es: 'Una persona. Los documentos se suben y se revisan a mano en la cola de administración.', + }, + }, + { + q: { + en: 'A pro’s insurance expires. Do they come off the platform automatically?', + es: 'El seguro de un profesional caduca. ¿Sale de la plataforma automáticamente?', + }, + today: { + en: 'The expiry date is stored. Nothing checks it.', + es: 'La fecha de caducidad se guarda. Nada la comprueba.', + }, + }, + ], + }, + { + group: { en: 'Launch', es: 'Lanzamiento' }, + items: [ + { + q: { + en: 'Launch with the trades we have supply for, or all fifty?', + es: '¿Lanzamos con los oficios para los que hay oferta, o con los cincuenta?', + }, + today: { + en: 'Fifty trades listed; eight have any pros. The rest look empty to a customer.', + es: 'Hay cincuenta oficios listados; ocho tienen profesionales. El resto se ven vacíos para un cliente.', + }, + }, + { + q: { + en: 'One city, or several from the start?', + es: '¿Una ciudad o varias desde el principio?', + }, + today: { + en: 'One. The city is a setting, so a second is configuration rather than a rebuild.', + es: 'Una. La ciudad es un ajuste, así que una segunda es configuración, no rehacer nada.', + }, + }, + { + q: { + en: 'Which events are worth an SMS, given each one costs money?', + es: '¿Qué eventos merecen un SMS, teniendo en cuenta que cada uno cuesta dinero?', + }, + today: { + en: 'A pro is texted about a new job and an answer. Messages and bookings are silent.', + es: 'Al profesional se le avisa por SMS de un trabajo nuevo y de una respuesta. Los mensajes y las reservas son silenciosos.', + }, + }, + ], + }, +]; + +/** + * Running costs, paid to DigitalOcean rather than to us. + * + * Listed per line rather than as one number because they scale independently — + * the database is the first thing that needs a bigger tier, and storage is the + * only one that grows with use. + */ +const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [ + { + item: { en: 'Managed Postgres', es: 'Postgres gestionado' }, + detail: { en: 'The database, with PostGIS', es: 'La base de datos, con PostGIS' }, + usd: 15, + }, + { + item: { en: 'Managed Redis', es: 'Redis gestionado' }, + detail: { en: 'Sessions, caching, job queue', es: 'Sesiones, caché y cola de trabajos' }, + usd: 15, + }, + { + item: { en: 'App Platform', es: 'App Platform' }, + detail: { en: 'Runs the app itself', es: 'Ejecuta la propia aplicación' }, + usd: 24, + }, + { + item: { en: 'Spaces', es: 'Spaces' }, + detail: { en: 'Photos and documents', es: 'Fotos y documentos' }, + usd: 5, + }, +]; + +const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0); + +const SECTIONS = { + features: { en: 'What it does', es: 'Qué hace' }, + stack: { en: 'Built with', es: 'Hecho con' }, + decisions: { en: 'Still to decide', es: 'Aún por decidir' }, + decisionsBody: { + en: 'Everything below already has a behaviour. These are the ones worth choosing deliberately rather than inheriting.', + es: 'Todo lo de abajo ya tiene un comportamiento. Estas son las decisiones que conviene tomar a propósito en lugar de heredarlas.', + }, + cost: { en: 'Delivery and cost', es: 'Entrega y coste' }, + build: { en: 'To build and launch', es: 'Construirlo y lanzarlo' }, + buildNote: { + en: 'One-off. Where it lands depends on the answers above.', + es: 'Pago único. Dónde caiga depende de las respuestas de arriba.', + }, + timeline: { en: 'Timeline', es: 'Plazo' }, + timelineValue: { en: '6–10 weeks', es: '6–10 semanas' }, + timelineNote: { + en: 'Six if the open questions are settled early, ten if they are not.', + es: 'Seis si las preguntas abiertas se cierran pronto, diez si no.', + }, + hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' }, + total: { en: 'Total', es: 'Total' }, + perMonth: { en: '/mo', es: '/mes' }, +} satisfies Record; + +/** The three caveats under the hosting table — lead sentence, then the rest. */ +const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [ + { + lead: { en: 'You pay DigitalOcean directly.', es: 'Pagas directamente a DigitalOcean.' }, + rest: { + en: ' This is not part of our fee and there is no markup on it — the account is yours, so you can see the bill and change the plan without going through us.', + es: ' No forma parte de nuestros honorarios y no lleva ningún recargo: la cuenta es tuya, así que puedes ver la factura y cambiar de plan sin pasar por nosotros.', + }, + }, + { + lead: { + en: `$${HOSTING_TOTAL} is the smallest tier of each.`, + es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`, + }, + rest: { + en: ' Enough to launch on and to run while the platform is finding its first customers.', + es: ' Suficiente para lanzar y para funcionar mientras la plataforma consigue sus primeros clientes.', + }, + }, + { + lead: { + en: 'Costs rise with use, unevenly.', + es: 'Los costes suben con el uso, de forma desigual.', + }, + rest: { + en: ' The database is the first thing that will need a larger plan; storage creeps up slowly as photos accumulate; the app itself can stay where it is for a long time.', + es: ' La base de datos es lo primero que necesitará un plan mayor; el almacenamiento crece despacio a medida que se acumulan fotos; la aplicación en sí puede quedarse donde está mucho tiempo.', + }, + }, +]; + +export function ProjectPanel() { + const [lang, setLang] = useState('en'); + const t = (copy: Copy) => copy[lang]; + + return ( + // `lang` on the wrapper, not only in state: it is what tells a screen reader + // which voice to read this in and a browser which dictionary to hyphenate by. +

+ {/* Above the title it changes, so the client sees the switch before they + have started reading. Right-aligned: it is a control on the panel, not + a heading of it, and the title keeps the left edge to itself. */} +
+
+ {LANGS.map(({ code, label }) => ( + + ))} +
+
+ +
+

Linkdr

+

{t(INTRO.title)}

+

{t(INTRO.body)}

+
+ +
+

{t(TRY.heading)}

+

{t(TRY.body)}

+ {/* Side by side: two short values do not need two full-width rows. + The code column is content-width so the number keeps the room. */} +
+ + +
+
+ +
+

{t(SECTIONS.features)}

+ {/* Tight two-column grid: small icon, title and its line on one row. + Read standing up, mid-sentence — so it has to scan, not be read. */} +
+ {FEATURES.map((f) => ( + // Keyed on the English string throughout: the key has to survive the + // toggle, or React remounts every row on a language change. +
+ + +
{t(f.title)}
+
— {t(f.body)}
+
+
+ ))} +
+
+ +
+

{t(SECTIONS.stack)}

+
+ {STACK.map((row) => ( +
+ {/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */} + {t(row.group)} + {row.items.map((item) => ( + + {item} + + ))} +
+ ))} +
+
+ +
+

{t(SECTIONS.decisions)}

+

{t(SECTIONS.decisionsBody)}

+
+ {DECISIONS.map((section) => ( +
+

{t(section.group)}

+
    + {section.items.map((item) => ( +
  • +

    + {t(item.q)} +

    +

    + {t(UI.today)} + {t(item.today)} +

    +
  • + ))} +
+
+ ))} +
+
+ + {/* Last, and deliberately after the decisions: the range IS the answer to + those questions, so quoting a single number above them would be a + promise made before the scope exists. */} +
+

{t(SECTIONS.cost)}

+ +
+
+

{t(SECTIONS.build)}

+

$4,400–6,000

+

{t(SECTIONS.buildNote)}

+
+
+

{t(SECTIONS.timeline)}

+

+ {t(SECTIONS.timelineValue)} +

+

{t(SECTIONS.timelineNote)}

+
+
+ +

{t(SECTIONS.hosting)}

+
    + {HOSTING.map((h) => ( +
  • + {t(h.item)} + {t(h.detail)} + ${h.usd} +
  • + ))} +
  • + {t(SECTIONS.total)} + + ${HOSTING_TOTAL} + {t(SECTIONS.perMonth)} + +
  • +
+
+ {HOSTING_NOTES.map((note) => ( +

+ {t(note.lead)} + {t(note.rest)} +

+ ))} +
+
+
+ ); +} + +/** + * A value to hand over verbatim, with one tap to copy. + * + * Reading a phone number off a screen into a form while somebody watches is a + * small humiliation; mistyping one in front of a client is a worse one. + */ +function CopyRow({ + label, + value, + copyLabel, + copiedLabel, + className, +}: { + label: string; + value: string; + copyLabel: string; + copiedLabel: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + return ( +
+ {label} + + {value} + + +
+ ); +} diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx index 14340fc..62459eb 100644 --- a/apps/web/src/components/deck.tsx +++ b/apps/web/src/components/deck.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react'; import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react'; -import type { DeckCard } from '@linkder/db'; +import type { DeckCard } from '@linkdr/db'; import { cn, formatDistance, formatResponseTime } from '@/lib/utils'; /** Horizontal drag past this many pixels commits the swipe. */ @@ -264,7 +264,7 @@ export function Card({ {formatDistance(card.distanceM)} - €{(card.hourlyRateCents / 100).toFixed(0)}/hr + ${(card.hourlyRateCents / 100).toFixed(0)}/hr {card.completedJobs > 0 && {card.completedJobs} jobs done}
diff --git a/apps/web/src/components/hire/ask-sheet.tsx b/apps/web/src/components/hire/ask-sheet.tsx index 72a2892..64c96ca 100644 --- a/apps/web/src/components/hire/ask-sheet.tsx +++ b/apps/web/src/components/hire/ask-sheet.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import type { DeckCard } from '@linkder/db'; +import type { DeckCard } from '@linkdr/db'; import { api } from '@/lib/trpc'; import { Banner, Button, Sheet, Textarea } from '@/components/ui'; import { setPendingHire } from '@/lib/pending-hire'; diff --git a/apps/web/src/components/hire/send-job-sheet.tsx b/apps/web/src/components/hire/send-job-sheet.tsx index 7783f0f..922b92c 100644 --- a/apps/web/src/components/hire/send-job-sheet.tsx +++ b/apps/web/src/components/hire/send-job-sheet.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { AlertTriangle, Check } from 'lucide-react'; -import type { DeckCard } from '@linkder/db'; +import { AlertTriangle, Check, CheckCircle2 } from 'lucide-react'; +import type { DeckCard } from '@linkdr/db'; import { api } from '@/lib/trpc'; import { SocialSignIn } from '@/components/auth/social-sign-in'; import { Banner, Button, Sheet } from '@/components/ui'; @@ -22,6 +22,7 @@ export function SendJobSheet({ pro, open, onResolved, + onViewJobs, }: { pro: DeckCard | null; open: boolean; @@ -30,9 +31,20 @@ export function SendJobSheet({ * `dismissed` when nothing happened and the card should come back. */ onResolved: (outcome: 'sent' | 'dismissed') => void; + /** Take them to the conversation they just started. */ + onViewJobs: () => void; }) { const router = useRouter(); const [chosen, setChosen] = useState(null); + /** + * What was just sent, and how long they have to answer. + * + * The sheet used to close the instant the mutation resolved, which is + * indistinguishable from nothing happening: the card is gone, the sheet is + * gone, and the one thing the person wanted to know — did it work — is the + * one thing not on screen. Sending a job to a stranger deserves a receipt. + */ + const [sent, setSent] = useState<{ jobTitle: string; expiresInHours: number } | null>(null); const me = api.user.me.useQuery(undefined, { retry: false }); const sendable = api.deck.sendable.useQuery( @@ -44,10 +56,15 @@ export function SendJobSheet({ const utils = api.useUtils(); const swipe = api.deck.swipe.useMutation({ - onSuccess: () => { + onSuccess: (result, variables) => { void utils.job.mine.invalidate(); void utils.deck.sendable.invalidate(); - onResolved('sent'); + + const job = sendable.data?.jobs.find((j) => j.id === variables.jobId); + setSent({ + jobTitle: job?.title ?? 'your job', + expiresInHours: result.requested ? result.expiresInHours : 0, + }); }, }); @@ -60,9 +77,58 @@ export function SendJobSheet({ const close = () => { setChosen(null); swipe.reset(); - onResolved('dismissed'); + // A sheet dismissed AFTER a successful send must not put the card back — + // the pro really does have the job now. + onResolved(sent ? 'sent' : 'dismissed'); + setSent(null); }; + /* ── sent ── */ + if (sent) { + return ( + + + +
+ } + > +
+ +
+

{name} has your job.

+

+ {sent.expiresInHours > 0 + ? `They have ${sent.expiresInHours} hours to answer. If they accept, a private conversation opens and you can agree a price there.` + : 'If they accept, a private conversation opens and you can agree a price there.'} +

+

+ You can send the same job to other pros while you wait — whoever answers first is + not automatically the one you book. +

+
+
+ + ); + } + /* ── anonymous ── */ if (!me.isLoading && (me.error || !me.data)) { return ( diff --git a/apps/web/src/components/jobs/deal-strip.tsx b/apps/web/src/components/jobs/deal-strip.tsx index 195b5ea..7141869 100644 --- a/apps/web/src/components/jobs/deal-strip.tsx +++ b/apps/web/src/components/jobs/deal-strip.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { CalendarClock, CheckCircle2, FileText } from 'lucide-react'; -import { formatCents } from '@linkder/shared'; +import { formatCents } from '@linkdr/shared'; import { api } from '@/lib/trpc'; import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui'; import { cn, formatWhen } from '@/lib/utils'; @@ -265,7 +265,7 @@ function QuoteSheet({ } >
diff --git a/apps/web/src/components/profile/skills-group.tsx b/apps/web/src/components/profile/skills-group.tsx index 5259fa1..d0200b2 100644 --- a/apps/web/src/components/profile/skills-group.tsx +++ b/apps/web/src/components/profile/skills-group.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Plus, X } from 'lucide-react'; -import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared'; +import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkdr/shared'; import { api } from '@/lib/trpc'; import { Button, FormError, Input, SettingsGroup } from '@/components/ui'; diff --git a/apps/web/src/components/search/result-row.tsx b/apps/web/src/components/search/result-row.tsx index bc0c237..0189a43 100644 --- a/apps/web/src/components/search/result-row.tsx +++ b/apps/web/src/components/search/result-row.tsx @@ -1,7 +1,7 @@ 'use client'; import { ChevronRight, Star } from 'lucide-react'; -import type { DeckCard } from '@linkder/db'; +import type { DeckCard } from '@linkdr/db'; import { formatDistance } from '@/lib/utils'; /** @@ -59,7 +59,7 @@ export function ResultRow({ pro, onOpen }: { pro: DeckCard; onOpen: (proId: stri {formatDistance(pro.distanceM)} · - €{(pro.hourlyRateCents / 100).toFixed(0)}/hr + ${(pro.hourlyRateCents / 100).toFixed(0)}/hr {pro.categories[0] && ( <> · diff --git a/apps/web/src/components/search/search-field.tsx b/apps/web/src/components/search/search-field.tsx index 87e5a3f..e78be80 100644 --- a/apps/web/src/components/search/search-field.tsx +++ b/apps/web/src/components/search/search-field.tsx @@ -1,7 +1,7 @@ 'use client'; import { Search, X } from 'lucide-react'; -import { MAX_SEARCH_QUERY_LENGTH } from '@linkder/shared'; +import { MAX_SEARCH_QUERY_LENGTH } from '@linkdr/shared'; import { Input } from '@/components/ui'; /** diff --git a/apps/web/src/components/search/search-filters.tsx b/apps/web/src/components/search/search-filters.tsx index 9d5300d..3f5ca16 100644 --- a/apps/web/src/components/search/search-filters.tsx +++ b/apps/web/src/components/search/search-filters.tsx @@ -1,8 +1,8 @@ 'use client'; import { SlidersHorizontal } from 'lucide-react'; -import type { SearchSort } from '@linkder/shared'; -import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared'; +import type { SearchSort } from '@linkdr/shared'; +import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkdr/shared'; import { Chip } from '@/components/ui'; import type { Category } from '@/app/showcase-deck'; diff --git a/apps/web/src/components/settings/account-footer.tsx b/apps/web/src/components/settings/account-footer.tsx new file mode 100644 index 0000000..138029b --- /dev/null +++ b/apps/web/src/components/settings/account-footer.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { Banner, Button, Sheet } from '@/components/ui'; +import { api } from '@/lib/trpc'; +import { authClient } from '@/lib/auth-client'; + +/** + * The two ways out, at the bottom where the thumb is. §9. + * + * Deletion used to be a red row inside "Legal and data", between the privacy + * policy and nothing — one tap, no confirmation, and styled like the two links + * above it. It is now the last thing on the screen, it is quieter than Sign out + * rather than louder, and the irreversible half happens inside a sheet, which + * is the rule §6.14 already stated and this was the case that broke it. + */ +export function AccountFooter() { + const [confirming, setConfirming] = useState(false); + const [requested, setRequested] = useState(false); + + const requestDeletion = api.user.requestDeletion.useMutation({ + onSuccess: () => { + setConfirming(false); + setRequested(true); + }, + }); + + return ( +
+ + + {requested ? ( + + We will action this within 30 days. Contact support if you change your mind. + + ) : ( + // A text button, not a filled red one. A big red button at the end of a + // scroll is a target; this has to be looked for. + + )} + + setConfirming(false)} + title="Delete your account?" + body="We action deletion requests within 30 days. Your jobs, messages and reviews go with it, and none of it can be brought back." + actions={ +
+ + +
+ } + /> +
+ ); +} diff --git a/apps/web/src/components/settings/account-section.tsx b/apps/web/src/components/settings/account-section.tsx new file mode 100644 index 0000000..328d6b6 --- /dev/null +++ b/apps/web/src/components/settings/account-section.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { + Button, + Field, + FormError, + Input, + SettingsGroup, + SettingsRow, + Sheet, + useToast, +} from '@/components/ui'; +import { api, type RouterOutputs } from '@/lib/trpc'; + +/** + * Who you are, at the top of the screen that is about you. + * + * This replaces four flat rows that read "Name — Not set". Both of the things + * worth changing here already had a mutation on the server and no way in from + * the UI, so the work was never "add editing" — it was to stop the settings + * screen presenting an editable fact as a fixed one. + */ + +type Me = RouterOutputs['user']['me']; + +/** Month and year only. A join date is context, not a timestamp. */ +const MONTH_YEAR = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' }); + +export function AccountSection({ me }: { me: Me }) { + const [editing, setEditing] = useState<'name' | 'email' | null>(null); + + return ( + <> + setEditing('name')} /> + + + setEditing('email')} + /> + {/* + 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. + */} + + + + setEditing(null)} + /> + setEditing(null)} /> + + ); +} + +/** + * The card the screen opens on. + * + * Tappable, and the chevron says so — the name is the one thing here anybody + * actually wants to change, and burying it in a list below its own display was + * how it ended up uneditable in the first place. + * + * The role is plain text rather than a pill on purpose. §4 principle 4: a pill + * is clickable. This is a fact about the account, and dressing a fact as a + * control is the same lie the chevron rows were telling. + */ +function IdentityCard({ me, onEdit }: { me: Me; onEdit: () => void }) { + const since = MONTH_YEAR.format(me.createdAt); + const role = me.role === 'pro' ? 'Professional' : 'Customer'; + + return ( + + ); +} + +function NameSheet({ + open, + initial, + onClose, +}: { + open: boolean; + initial: string; + onClose: () => void; +}) { + const utils = api.useUtils(); + const [name, setName] = useState(initial); + const [error, setError] = useState(null); + + // The sheet stays mounted so it can animate out, so its draft has to be reset + // on the way in — otherwise a cancelled edit is still sitting there next time. + useEffect(() => { + if (!open) return; + setName(initial); + setError(null); + }, [open, initial]); + + const save = api.user.updateProfile.useMutation({ + onSuccess: () => { + void utils.user.me.invalidate(); + onClose(); + }, + onError: (e) => setError(e.message), + }); + + const trimmed = name.trim(); + const unchanged = trimmed === initial.trim(); + + return ( + save.mutate({ name: trimmed })} + > + Save + + } + > + + { + setName(e.target.value); + setError(null); + }} + /> + + {error && {error}} + + ); +} + +/** + * Asking for an address, not setting one. + * + * `requestEmailChange` parks the address until a token comes back, and it + * deliberately never reports a collision — so there is no failure state to show + * here beyond a malformed address, and no "saved" state either. What happened + * happened in an inbox, which is exactly the outcome a toast is for. + */ +function EmailSheet({ open, onClose }: { open: boolean; onClose: () => void }) { + const toast = useToast(); + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setEmail(''); + setError(null); + }, [open]); + + const request = api.user.requestEmailChange.useMutation({ + onSuccess: () => { + toast('Check your inbox — we sent a link to confirm the address.', { tone: 'success' }); + onClose(); + }, + onError: (e) => setError(e.message), + }); + + const trimmed = email.trim(); + + return ( + request.mutate({ email: trimmed })} + > + Send confirmation + + } + > + + { + setEmail(e.target.value); + setError(null); + }} + /> + + {error && {error}} + + ); +} diff --git a/apps/web/src/components/settings/location-group.tsx b/apps/web/src/components/settings/location-group.tsx index c782bee..8cc5497 100644 --- a/apps/web/src/components/settings/location-group.tsx +++ b/apps/web/src/components/settings/location-group.tsx @@ -5,7 +5,7 @@ import { DEFAULT_SERVICE_RADIUS_M, MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { api } from '@/lib/trpc'; import { AddressField, diff --git a/apps/web/src/components/ui/address-field.tsx b/apps/web/src/components/ui/address-field.tsx index b528a3f..9619368 100644 --- a/apps/web/src/components/ui/address-field.tsx +++ b/apps/web/src/components/ui/address-field.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react'; -import type { LocationInput } from '@linkder/shared'; +import type { LocationInput } from '@linkdr/shared'; import { api } from '@/lib/trpc'; import { useDebouncedValue } from '@/lib/use-debounced-value'; import { cn } from '@/lib/utils'; diff --git a/apps/web/src/components/ui/banner.tsx b/apps/web/src/components/ui/banner.tsx index 9256463..9a85dbe 100644 --- a/apps/web/src/components/ui/banner.tsx +++ b/apps/web/src/components/ui/banner.tsx @@ -64,7 +64,7 @@ export function Banner({ tone = 'info', title, children, className, role }: Bann /** Inline form error. §6.2 — always announced. */ export function FormError({ children }: { children: React.ReactNode }) { return ( -

+

{children}

); diff --git a/apps/web/src/components/ui/field.tsx b/apps/web/src/components/ui/field.tsx index 93e6daa..0af5b07 100644 --- a/apps/web/src/components/ui/field.tsx +++ b/apps/web/src/components/ui/field.tsx @@ -62,7 +62,7 @@ export function Field({ {hint && {hint}} {children} {error && ( - + {error} )} diff --git a/apps/web/src/components/ui/settings-row.tsx b/apps/web/src/components/ui/settings-row.tsx index cbe96ab..a22ca66 100644 --- a/apps/web/src/components/ui/settings-row.tsx +++ b/apps/web/src/components/ui/settings-row.tsx @@ -1,9 +1,16 @@ 'use client'; +import Link from 'next/link'; import { ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; -/** A titled group of rows. Settings is a list of lists. */ +/** + * A titled group of rows. Settings is a list of lists. + * + * The gap below is 32px rather than 24px because these are the "blocks within a + * screen" of §4, not siblings in a list — at 24px the group titles stopped + * reading as titles and the page became one undifferentiated stack of boxes. + */ export function SettingsGroup({ title, note, @@ -14,7 +21,7 @@ export function SettingsGroup({ children: React.ReactNode; }) { return ( -
+

{title}

{children} @@ -24,11 +31,23 @@ export function SettingsGroup({ ); } -/** A read-only or navigational row. */ +/** Shared by every row shape below, so a group never looks stitched together. */ +const rowClasses = 'flex w-full items-center gap-3 px-4 py-3.5 text-left'; +const interactiveClasses = 'transition-colors duration-[120ms] ease-standard hover:bg-sunken'; + +/** + * A read-only, navigational, or linking row. + * + * `href` and `onClick` are alternatives, and one of them must be present for the + * row to draw a chevron. A row that shows the chevron and does nothing is the + * worst state available here — it is indistinguishable from a broken link, so + * the affordance is tied to the destination rather than set by hand. + */ export function SettingsRow({ label, value, hint, + href, onClick, danger, disabled, @@ -36,34 +55,48 @@ export function SettingsRow({ label: string; value?: React.ReactNode; hint?: string; + href?: string; onClick?: () => void; danger?: boolean; disabled?: boolean; }) { - const interactive = Boolean(onClick) && !disabled; - const Tag = interactive ? 'button' : 'div'; + const interactive = Boolean(href ?? onClick) && !disabled; - return ( - + const inner = ( + <> - + {label} {hint && {hint}} {value !== undefined && ( - {value} + // Shrinkable and truncating, NOT shrink-0: an email long enough to need + // the room used to take it from the label, which collapsed to nothing + // while the value it was labelling ran on past the bezel. + {value} )} {interactive && } - + ); + + if (href && !disabled) { + return ( + + {inner} + + ); + } + + if (onClick && !disabled) { + return ( + + ); + } + + return
{inner}
; } /** @@ -91,8 +124,8 @@ export function SettingsToggle({ disabled={disabled} onClick={() => onChange(!checked)} className={cn( - 'flex w-full items-center gap-3 px-4 py-3.5 text-left', - 'transition-colors duration-[120ms] ease-standard hover:bg-sunken', + rowClasses, + interactiveClasses, disabled && 'pointer-events-none opacity-45', )} > @@ -105,7 +138,10 @@ export function SettingsToggle({ className={cn( 'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill', 'transition-colors duration-[120ms] ease-standard', - checked ? 'bg-brand-500' : 'bg-ink-300', + // ink-500, not ink-300: an off switch is a UI component and owes 3:1 + // against the row behind it (§8). ink-300 measured 1.78:1 on white, + // which made "off" read as "disabled" as much as it read as a state. + checked ? 'bg-brand-500' : 'bg-ink-500', )} > { await sendSms( to, - `${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`, + `${code} is your Linkdr code. It expires in 5 minutes. We will never ask you for it.`, ); } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 6f8c4ac..9ac7b22 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1,7 +1,7 @@ @import 'tailwindcss'; /* - * Linkder design tokens — see DESIGN.md at the repo root. + * Linkdr design tokens — see DESIGN.md at the repo root. * Hex values are sampled from wix.com and are normative. Do not hand-tune them * in a component; change them here or add a step to the ramp. */ @@ -138,6 +138,14 @@ --accent: var(--color-brand-500); --accent-hover: var(--color-brand-600); --accent-soft: var(--color-brand-50); + /* + * Destructive TEXT, which is a different job from the destructive fill. + * stop-500 is the error colour of §2.3 and stays the fill and icon colour — + * as a graphical object it only owes 3:1 and it clears that. As body text it + * measures 3.97:1 on white and misses the 4.5:1 floor in §8, so a label that + * says "Delete my account" uses this instead. §8 is the floor; it wins. + */ + --danger: var(--color-stop-600); } @media (prefers-color-scheme: dark) { @@ -154,6 +162,9 @@ --accent: var(--color-brand-400); --accent-hover: var(--color-brand-200); --accent-soft: rgb(94 151 255 / 0.14); + /* Inverted for the same reason: stop-600 is 3.0:1 on the dark page. §2.3 + already names stop-400 "error on dark" — this is where that gets used. */ + --danger: var(--color-stop-400); } } @@ -170,6 +181,7 @@ --color-accent: var(--accent); --color-accent-hover: var(--accent-hover); --color-accent-soft: var(--accent-soft); + --color-danger: var(--danger); } @layer base { diff --git a/apps/web/test/auth.test.ts b/apps/web/test/auth.test.ts index 0516364..fcf854c 100644 --- a/apps/web/test/auth.test.ts +++ b/apps/web/test/auth.test.ts @@ -2,7 +2,7 @@ * Auth integration test — runs against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/web test + * pnpm --filter @linkdr/web test * * This is deliberately an integration test rather than a unit test, because the * thing most likely to break is not our logic. better-auth declares @@ -18,9 +18,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db, schema } = await import('@linkder/db'); +const { closePool, db, schema } = await import('@linkdr/db'); const { auth } = await import('@/lib/auth'); -const { isSyntheticEmail } = await import('@linkder/shared'); +const { isSyntheticEmail } = await import('@linkdr/shared'); /** A number no seed row uses, so the test owns its own user. */ const PHONE = '+34699000111'; diff --git a/apps/web/test/observability.test.ts b/apps/web/test/observability.test.ts index d0ec3e9..10f0dbf 100644 --- a/apps/web/test/observability.test.ts +++ b/apps/web/test/observability.test.ts @@ -56,7 +56,7 @@ describe('beforeSend', () => { it('drops cookies and headers entirely', () => { const event = { request: { - url: 'https://linkder.app/api', + url: 'https://linkdr.app/api', cookies: { session: 'live-credential' }, headers: { authorization: 'Bearer live-credential' }, }, @@ -88,7 +88,7 @@ describe('beforeSend', () => { it('scrubs request body data and the query string', () => { const event = { request: { - url: 'https://linkder.app/verify?code=123456', + url: 'https://linkdr.app/verify?code=123456', query_string: 'code=123456', data: { phoneNumber: '+34600111222', code: '123456' }, }, diff --git a/ca-certificate.crt b/ca-certificate.crt new file mode 100644 index 0000000..2f77476 --- /dev/null +++ b/ca-certificate.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIERDCCAqygAwIBAgIUT+Zwrrq80kuT4VpUr5nmPHR8eZ0wDQYJKoZIhvcNAQEM +BQAwOjE4MDYGA1UEAwwvOTFkNTNlYjctMDA5MC00Mzg5LWFjN2EtYjAxN2U2Mjli +NjY0IFByb2plY3QgQ0EwHhcNMjYwNDE2MTMwNzMzWhcNMzYwNDEzMTMwNzMzWjA6 +MTgwNgYDVQQDDC85MWQ1M2ViNy0wMDkwLTQzODktYWM3YS1iMDE3ZTYyOWI2NjQg +UHJvamVjdCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKFiJhw2 +DC3Xf0HzVb+glBzZajAPgkJP0EwRN4sdxPNR17ajN4mFuDXIjHuil/zhJiwVbByC +NA1NX+2wPNJ9MZVSVyYvB6G8xRhBFvlawS+u5KQQ6TqdJ02/398D5cY5L1vbRzNU +CZgrDtzuztOlER02dltmE8/mZmKN6rh+p7gyYVRNe+uXMn9VJQj5853fA0yw6OZh +1CJdf6xUqVCROf6PaTaeOKq0tu/1YkvKjY/cNioOgAHZe3WcKixdbnjXAwU3P4RU +A7CHunxfccIGh32lItz/pSwFGIvEaUcbEcu+343DtO0ADRELNQLXREOUduJTRaNs +kApA3YRGImi56CagCklrzL6kAGC6yxRqQDAMIcad11Msk6qreCOy0ozQeg8MRvSy +m9qNoOt1OsQXykI0CjsmG/R+lAO3DDL0Fgf34Vaq8BDDAYa4GhwIuWQKOkiotC8q +XDLkdc6AYvmGYEr8HfQ+r82Ydbbg4Sp2FSUeTKDoWLt2mgwsP4X+ouT16QIDAQAB +o0IwQDAdBgNVHQ4EFgQUnCW66rguV+CMWB4qWelnjPwMoV0wEgYDVR0TAQH/BAgw +BgEB/wIBADALBgNVHQ8EBAMCAQYwDQYJKoZIhvcNAQEMBQADggGBAC0JQDGjl+oY +GcvpvSMylDo7SY+WvvB1bGaW3lPoh97qPdVdlyAQKKSu4np/hygeJvaX3+I4Ongw +GVrdP0OsqIcB51W7c/ktg5BhBhKyXyiXDLHKHovIB0kyK3x4D9J2jHfjBjYzD/eX +Fy52AFyZnvVeiRjOh2ZCUpKdCKRjQwtX5c36NQEMn6APCy3toduSPoxjsTsnUan3 +Kq1bAq2YPnwkNwfpNHE2IYqTnAhp+EjJDPmttcFtxoDQBbQ1V0Ug2oxWxj2N7w2S +x6rWF3XTf3jl7ZX5FNk2s6es4BXXw9A0Jylq80t1TZ3BKJdS9thCyWrLlrOcb//W +qaqxZtOfqyAG8FgFRO22cHJEEi0oANKU9ZNMG7zq83oEKkFFs5eGE+9eiBCXP/sx +oUpw/Le4D6oajrqHMp2R2bQXbX+tId2RCsYTwF+3MkRJk0cCQQa3d6d7S8/c/y8c +XgcnmXkdLOsegv6Xt7BsCxNn74YNxwJCP34LdUaFmkPIC2ZcVqY+Gg== +-----END CERTIFICATE----- diff --git a/package.json b/package.json index 714df06..880aa1e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "linkder", + "name": "linkdr", "private": true, "packageManager": "pnpm@9.15.4", "engines": { @@ -13,13 +13,14 @@ "test": "turbo run test --concurrency=1", "test:e2e": "turbo run test:e2e", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"", - "db:generate": "pnpm --filter @linkder/db generate", - "db:migrate": "pnpm --filter @linkder/db migrate", - "db:seed": "pnpm --filter @linkder/db seed", - "db:recompute-stats": "pnpm --filter @linkder/db recompute-stats", - "db:studio": "pnpm --filter @linkder/db studio", + "db:generate": "pnpm --filter @linkdr/db generate", + "db:migrate": "pnpm --filter @linkdr/db migrate", + "db:seed": "pnpm --filter @linkdr/db seed", + "db:recompute-stats": "pnpm --filter @linkdr/db recompute-stats", + "db:studio": "pnpm --filter @linkdr/db studio", "services:up": "docker compose up -d postgres redis", - "services:down": "docker compose down" + "services:down": "docker compose down", + "assets:migrate": "pnpm --filter @linkdr/db assets:migrate" }, "devDependencies": { "@types/node": "^22.10.5", diff --git a/packages/api/package.json b/packages/api/package.json index 2cc8a94..5f2160f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/api", + "name": "@linkdr/api", "version": "0.0.0", "private": true, "type": "module", @@ -14,11 +14,11 @@ "test": "vitest run" }, "dependencies": { - "@linkder/db": "workspace:*", - "@linkder/geocode": "workspace:*", - "@linkder/notify": "workspace:*", - "@linkder/shared": "workspace:*", - "@linkder/storage": "workspace:*", + "@linkdr/db": "workspace:*", + "@linkdr/geocode": "workspace:*", + "@linkdr/notify": "workspace:*", + "@linkdr/shared": "workspace:*", + "@linkdr/storage": "workspace:*", "@opentelemetry/api": "1.9.1", "@trpc/server": "^11.18.0", "drizzle-orm": "0.38.4", diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 3c743a6..4277015 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -1,5 +1,5 @@ -import type { Db } from '@linkder/db'; -import type { Role, VerificationStatus } from '@linkder/shared'; +import type { Db } from '@linkdr/db'; +import type { Role, VerificationStatus } from '@linkdr/shared'; /** * The session shape the API depends on. diff --git a/packages/api/src/location.ts b/packages/api/src/location.ts index 992041d..4664930 100644 --- a/packages/api/src/location.ts +++ b/packages/api/src/location.ts @@ -1,5 +1,5 @@ -import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkder/geocode'; -import type { LatLng, LocationInput, LocationPrecision } from '@linkder/shared'; +import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkdr/geocode'; +import type { LatLng, LocationInput, LocationPrecision } from '@linkdr/shared'; /** * The one place a stored coordinate is decided. diff --git a/packages/api/src/routers/admin.ts b/packages/api/src/routers/admin.ts index b51fbef..3224217 100644 --- a/packages/api/src/routers/admin.ts +++ b/packages/api/src/routers/admin.ts @@ -1,10 +1,10 @@ import { TRPCError } from '@trpc/server'; import { and, asc, count, desc, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; -import { recomputeProStats, schema } from '@linkder/db'; -import { notify } from '@linkder/notify'; -import { createPresignedDownload } from '@linkder/storage'; -import { assertTransition, VERIFICATION_STATUSES } from '@linkder/shared'; +import { recomputeProStats, schema } from '@linkdr/db'; +import { notify } from '@linkdr/notify'; +import { createPresignedDownload } from '@linkdr/storage'; +import { assertTransition, VERIFICATION_STATUSES } from '@linkdr/shared'; import { adminProcedure, router } from '../trpc'; /** @@ -20,7 +20,7 @@ import { adminProcedure, router } from '../trpc'; * everyone else: an admin surface that announces itself is a target. * * Nothing in this router trusts a status it was handed. Each transition goes - * through the graph in @linkder/shared, and each writes an `audit_log` row — + * through the graph in @linkdr/shared, and each writes an `audit_log` row — * these are the decisions that a regulator, an insurer or a court would ask us * to account for. */ diff --git a/packages/api/src/routers/booking.ts b/packages/api/src/routers/booking.ts index b2b4e14..be9cc7c 100644 --- a/packages/api/src/routers/booking.ts +++ b/packages/api/src/routers/booking.ts @@ -1,8 +1,8 @@ import { TRPCError } from '@trpc/server'; import { desc, eq } from 'drizzle-orm'; import { z } from 'zod'; -import { recomputeProStats, schema, type Db } from '@linkder/db'; -import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkder/shared'; +import { recomputeProStats, schema, type Db } from '@linkdr/db'; +import { assertTransition, cancellationOutcome, type BookingStatus } from '@linkdr/shared'; import { requireMatchParticipant } from './message'; import { protectedProcedure, router } from '../trpc'; @@ -162,7 +162,7 @@ export const bookingRouter = router({ * Call it off. * * Either side may, and who cancelled decides who pays — `cancellationOutcome` - * in @linkder/shared owns that rule and is already tested. The result is + * in @linkdr/shared owns that rule and is already tested. The result is * written into the audit log now, while the scheduled time and the quote are * still the facts they were; recomputing it later from a slot that has since * passed would give a different answer. diff --git a/packages/api/src/routers/deck.ts b/packages/api/src/routers/deck.ts index 0e70cc9..be2bd97 100644 --- a/packages/api/src/routers/deck.ts +++ b/packages/api/src/routers/deck.ts @@ -1,14 +1,14 @@ import { TRPCError } from '@trpc/server'; import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db'; -import { notify } from '@linkder/notify'; +import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkdr/db'; +import { notify } from '@linkdr/notify'; import { DECK_PAGE_SIZE, MAX_OPEN_REQUESTS_PER_JOB, REQUEST_TTL_HOURS, swipeSchema, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { clientProcedure, publicProcedure, router } from '../trpc'; import type { Context } from '../context'; diff --git a/packages/api/src/routers/enquiry.ts b/packages/api/src/routers/enquiry.ts index e42d0ff..b09567f 100644 --- a/packages/api/src/routers/enquiry.ts +++ b/packages/api/src/routers/enquiry.ts @@ -1,8 +1,8 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq, gt, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; -import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkder/shared'; +import { schema } from '@linkdr/db'; +import { ENQUIRY_STALE_DAYS, MAX_OPEN_ENQUIRIES } from '@linkdr/shared'; import { clientProcedure, proProcedure, protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/routers/geocode.ts b/packages/api/src/routers/geocode.ts index ddb96ff..39cf8c6 100644 --- a/packages/api/src/routers/geocode.ts +++ b/packages/api/src/routers/geocode.ts @@ -7,8 +7,8 @@ import { MAX_SUGGESTIONS, reverse, type GeocodeResult, -} from '@linkder/geocode'; -import { latLngSchema } from '@linkder/shared'; +} from '@linkdr/geocode'; +import { latLngSchema } from '@linkdr/shared'; import { protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/routers/job.ts b/packages/api/src/routers/job.ts index 0c3d002..476b23f 100644 --- a/packages/api/src/routers/job.ts +++ b/packages/api/src/routers/job.ts @@ -1,8 +1,8 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; -import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkder/shared'; +import { schema } from '@linkdr/db'; +import { ACTIVE_JOB_STATUSES, assertTransition, createJobSchema } from '@linkdr/shared'; import { resolveLocation } from '../location'; import { clientProcedure, proProcedure, publicProcedure, router } from '../trpc'; diff --git a/packages/api/src/routers/message.ts b/packages/api/src/routers/message.ts index 8c2da66..d563264 100644 --- a/packages/api/src/routers/message.ts +++ b/packages/api/src/routers/message.ts @@ -1,8 +1,8 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq, isNull, ne, or, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { schema, type Db } from '@linkder/db'; -import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkder/shared'; +import { schema, type Db } from '@linkdr/db'; +import { PAST_JOB_STATUSES, sendMessageSchema, type JobStatus } from '@linkdr/shared'; import { protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/routers/notification.ts b/packages/api/src/routers/notification.ts index 3d1753f..1103030 100644 --- a/packages/api/src/routers/notification.ts +++ b/packages/api/src/routers/notification.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; +import { schema } from '@linkdr/db'; import { protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/routers/pro.ts b/packages/api/src/routers/pro.ts index c573f40..3e51fee 100644 --- a/packages/api/src/routers/pro.ts +++ b/packages/api/src/routers/pro.ts @@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq, inArray, lt } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { z } from 'zod'; -import { eligibleProAtAnyDistance, schema, searchPros } from '@linkder/db'; -import { notify } from '@linkder/notify'; +import { eligibleProAtAnyDistance, schema, searchPros } from '@linkdr/db'; +import { notify } from '@linkdr/notify'; import { assertTransition, credentialSchema, @@ -12,7 +12,7 @@ import { REVIEWS_PAGE_SIZE, searchProsSchema, updateSkillsSchema, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { resolveLocation } from '../location'; import { proProcedure, publicProcedure, router } from '../trpc'; diff --git a/packages/api/src/routers/quote.ts b/packages/api/src/routers/quote.ts index 495e5e1..ac8eea7 100644 --- a/packages/api/src/routers/quote.ts +++ b/packages/api/src/routers/quote.ts @@ -1,13 +1,13 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; +import { schema } from '@linkdr/db'; import { assertTransition, createBookingSchema, createQuoteSchema, QUOTE_VALIDITY_HOURS, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { requireMatchParticipant } from './message'; import { clientProcedure, protectedProcedure, router, verifiedProProcedure } from '../trpc'; diff --git a/packages/api/src/routers/request.ts b/packages/api/src/routers/request.ts index 556a28e..ca5381d 100644 --- a/packages/api/src/routers/request.ts +++ b/packages/api/src/routers/request.ts @@ -1,9 +1,9 @@ import { TRPCError } from '@trpc/server'; import { and, eq, gt, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { recomputeProStats, schema } from '@linkder/db'; -import { notify } from '@linkder/notify'; -import { assertTransition } from '@linkder/shared'; +import { recomputeProStats, schema } from '@linkdr/db'; +import { notify } from '@linkdr/notify'; +import { assertTransition } from '@linkdr/shared'; import { proProcedure, router, verifiedProProcedure } from '../trpc'; /** diff --git a/packages/api/src/routers/review.ts b/packages/api/src/routers/review.ts index beffe3c..1a107fd 100644 --- a/packages/api/src/routers/review.ts +++ b/packages/api/src/routers/review.ts @@ -1,12 +1,12 @@ import { TRPCError } from '@trpc/server'; import { and, eq, ne, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { recomputeProStats, schema } from '@linkder/db'; +import { recomputeProStats, schema } from '@linkdr/db'; import { createReviewSchema, REVIEW_EMBARGO_HOURS, REVIEW_WINDOW_DAYS, -} from '@linkder/shared'; +} from '@linkdr/shared'; import { requireMatchParticipant } from './message'; import { protectedProcedure, router } from '../trpc'; diff --git a/packages/api/src/routers/upload.ts b/packages/api/src/routers/upload.ts index 9d4f2ea..4d0e53f 100644 --- a/packages/api/src/routers/upload.ts +++ b/packages/api/src/routers/upload.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkder/storage'; +import { createPresignedUpload, publicUrl, uploadRequestSchema, StorageError } from '@linkdr/storage'; import { protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index c003976..fb313f9 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -2,8 +2,8 @@ import { randomUUID } from 'node:crypto'; import { TRPCError } from '@trpc/server'; import { and, desc, eq, isNull } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; -import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared'; +import { schema } from '@linkdr/db'; +import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkdr/shared'; import { resolveLocation } from '../location'; import { protectedProcedure, publicProcedure, router } from '../trpc'; diff --git a/packages/api/src/routers/watch.ts b/packages/api/src/routers/watch.ts index 801fbc5..4c78f9a 100644 --- a/packages/api/src/routers/watch.ts +++ b/packages/api/src/routers/watch.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { schema } from '@linkder/db'; +import { schema } from '@linkdr/db'; import { protectedProcedure, router } from '../trpc'; /** diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 3c92e81..40bb027 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from '@trpc/server'; import superjson from 'superjson'; import { ZodError } from 'zod'; import { eq } from 'drizzle-orm'; -import { schema } from '@linkder/db'; +import { schema } from '@linkdr/db'; import type { Context } from './context'; const t = initTRPC.context().create({ diff --git a/packages/api/test/admin.router.test.ts b/packages/api/test/admin.router.test.ts index 75c04ae..379425a 100644 --- a/packages/api/test/admin.router.test.ts +++ b/packages/api/test/admin.router.test.ts @@ -23,7 +23,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -86,7 +86,7 @@ async function makePro(name: string, status: string): Promise { ${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000, -- Right on the city centre, so an approval is visible to a search run -- from there and the "it lands on every surface" assertions are real. - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status} + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 20000, ${status} ) `); diff --git a/packages/api/test/deck.router.test.ts b/packages/api/test/deck.router.test.ts index 456abc1..24813c0 100644 --- a/packages/api/test/deck.router.test.ts +++ b/packages/api/test/deck.router.test.ts @@ -2,7 +2,7 @@ * Integration tests for the deck router, run against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/api test + * pnpm --filter @linkdr/api test * * The point of these is authorization. The swipe path previously lived in a Next * server action that trusted whatever jobId it was handed, so anyone could swipe @@ -11,11 +11,11 @@ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared'; +import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkdr/shared'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); diff --git a/packages/api/test/discovery.router.test.ts b/packages/api/test/discovery.router.test.ts index 248ef7b..f59a4b5 100644 --- a/packages/api/test/discovery.router.test.ts +++ b/packages/api/test/discovery.router.test.ts @@ -12,11 +12,11 @@ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { MAX_OPEN_ENQUIRIES } from '@linkder/shared'; +import { MAX_OPEN_ENQUIRIES } from '@linkdr/shared'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); diff --git a/packages/api/test/geocode.router.test.ts b/packages/api/test/geocode.router.test.ts index a21bbe2..82c5231 100644 --- a/packages/api/test/geocode.router.test.ts +++ b/packages/api/test/geocode.router.test.ts @@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -37,8 +37,8 @@ const clientSession = (userId: string): Session => ({ const RUN = Math.random().toString(36).slice(2, 8); const CITY = { - lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), - lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), + lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), + lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), }; let client: string; @@ -140,13 +140,13 @@ describe('job.create resolves the point server-side', () => { const job = await callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, - place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' }, + place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa' }, }); const row = await readJob(job.id); // A handset fix is real, so the coordinates are kept as sent... - expect(Number(row.lat)).toBeCloseTo(41.4036, 4); - expect(Number(row.lng)).toBeCloseTo(2.1744, 4); + expect(Number(row.lat)).toBeCloseTo(19.4194, 4); + expect(Number(row.lng)).toBeCloseTo(-99.1655, 4); // ...but it is metres out on a good day, so it must not rank as a rooftop. expect(row.precision).toBe('approximate'); }); @@ -157,7 +157,7 @@ describe('job.create resolves the point server-side', () => { const job = await callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, - place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' }, + place: { source: 'place', placeId: 'made-up-id', label: 'Av. Álvaro Obregón 12' }, }); const row = await readJob(job.id); diff --git a/packages/api/test/lifecycle.router.test.ts b/packages/api/test/lifecycle.router.test.ts index 08aad1c..2e2f9bd 100644 --- a/packages/api/test/lifecycle.router.test.ts +++ b/packages/api/test/lifecycle.router.test.ts @@ -15,11 +15,11 @@ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { REVIEW_EMBARGO_HOURS } from '@linkder/shared'; +import { REVIEW_EMBARGO_HOURS } from '@linkdr/shared'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -98,7 +98,7 @@ beforeAll(async () => { ) VALUES ( ${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000, - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact', 15000, 'verified', now(), false ) `); @@ -109,8 +109,8 @@ beforeAll(async () => { VALUES ( ${owner}, ${plumber!.id}, 'Lifecycle fixture job', 'A job that exists to be quoted, booked, completed and reviewed.', - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', - 'Carrer de Prova 1', 'matched' + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact', + 'Calle Colima 1', 'matched' ) RETURNING id `); @@ -220,7 +220,7 @@ describe('booking', () => { ) VALUES ( ${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500, - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact', 15000, 'verified', now(), false ) `); diff --git a/packages/api/test/message.router.test.ts b/packages/api/test/message.router.test.ts index df0767f..68b954e 100644 --- a/packages/api/test/message.router.test.ts +++ b/packages/api/test/message.router.test.ts @@ -2,7 +2,7 @@ * Integration tests for chat, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/api test + * pnpm --filter @linkdr/api test * * A thread is a private conversation between exactly two people, so most of this * file is about the third person: a stranger must not be able to read it, write @@ -18,7 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -78,8 +78,8 @@ async function insertJobWithMatch(categoryId: string, status: string): Promise<{ VALUES ( ${owner}, ${categoryId}, 'Chat fixture job', 'A job that exists only so a conversation can hang off it.', - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, - 'Carrer de Prova 1', ${sql.raw(`'${status}'`)} + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, + 'Calle Colima 1', ${sql.raw(`'${status}'`)} ) RETURNING id `); @@ -116,7 +116,7 @@ beforeAll(async () => { ) VALUES ( ${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000, - ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now() + ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 15000, 'verified', now() ) `); diff --git a/packages/api/test/pro.router.test.ts b/packages/api/test/pro.router.test.ts index e8b4edb..555f7d9 100644 --- a/packages/api/test/pro.router.test.ts +++ b/packages/api/test/pro.router.test.ts @@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); diff --git a/packages/api/test/reviews.router.test.ts b/packages/api/test/reviews.router.test.ts index abedb54..b65e9d3 100644 --- a/packages/api/test/reviews.router.test.ts +++ b/packages/api/test/reviews.router.test.ts @@ -19,7 +19,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -45,12 +45,12 @@ let probeClient: string; beforeAll(async () => { const [marc] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`, ); reviewedPro = marc!.id; const [arnau] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`, ); awayPro = arnau!.id; @@ -194,7 +194,7 @@ describe('pro.reviews', () => { }); it('is not a way to read a pro who is off the deck', async () => { - // Away Arnau has a seeded review history and is verified — only holiday mode + // Away Arturo has a seeded review history and is verified — only holiday mode // hides him. If this stopped 404ing, reviews would be the way around // publicProfile rather than a view onto it. await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i); @@ -205,7 +205,7 @@ describe('pro.reviews', () => { it('404s for an unverified pro, exactly as the profile does', async () => { const [ulla] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`, ); await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i); }); diff --git a/packages/api/test/search.router.test.ts b/packages/api/test/search.router.test.ts index 9ae383a..0a30419 100644 --- a/packages/api/test/search.router.test.ts +++ b/packages/api/test/search.router.test.ts @@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -62,12 +62,12 @@ beforeAll(async () => { client = aClient!.id; const [marc] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`, ); verifiedPro = marc!.id; const [arnau] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`, ); awayPro = arnau!.id; @@ -121,7 +121,7 @@ describe('pro.search', () => { const ids = results.map((p) => p.proId); const names = results.map((p) => p.name); - expect(names).not.toContain('Unverified Ulla'); + expect(names).not.toContain('Unverified Ulises'); expect(ids).not.toContain(awayPro); expect(ids).not.toContain(bannedPro); }); @@ -131,7 +131,7 @@ describe('pro.search', () => { // the pros next to the city centre must fall out of range. await db.execute(sql` UPDATE users - SET location = ST_SetSRID(ST_MakePoint(2.1686, 41.5674), 4326)::geography, + SET location = ST_SetSRID(ST_MakePoint(-99.1332, 19.6126), 4326)::geography, search_radius_m = 2000 WHERE id = ${client} `); @@ -166,7 +166,7 @@ describe('pro.publicProfile', () => { it('refuses a pro who was never verified', async () => { const [ulla] = await db.execute<{ id: string }>( - sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`, + sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`, ); await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow(); }); diff --git a/packages/api/test/settings.router.test.ts b/packages/api/test/settings.router.test.ts index 89b4da5..f227db3 100644 --- a/packages/api/test/settings.router.test.ts +++ b/packages/api/test/settings.router.test.ts @@ -13,7 +13,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); @@ -258,15 +258,15 @@ describe('location and range', () => { // coordinates the server takes at face value, so this test does not need a // geocoder to be configured. await caller.user.updateLocation({ - place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' }, + place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa, Ciudad de México' }, radiusM: 8_000, }); const location = await caller.user.location(); - expect(location.addressText).toBe('Gracia, Barcelona'); + expect(location.addressText).toBe('Condesa, Ciudad de México'); expect(location.radiusM).toBe(8_000); - expect(location.location?.lat).toBeCloseTo(41.4036, 4); - expect(location.location?.lng).toBeCloseTo(2.1744, 4); + expect(location.location?.lat).toBeCloseTo(19.4194, 4); + expect(location.location?.lng).toBeCloseTo(-99.1655, 4); }); it('changes only what it was given', async () => { @@ -276,7 +276,7 @@ describe('location and range', () => { 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); + expect(location.location?.lat).toBeCloseTo(19.4194, 4); }); it('refuses a radius outside the supported range', async () => { diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 5b8ca5f..1c6a53a 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -1,13 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; import { config } from 'dotenv'; import { defineConfig } from 'drizzle-kit'; config({ path: '../../.env' }); +/** + * Same CA rule as src/client.ts, restated because drizzle-kit runs this file on + * its own and cannot import from the package it is generating for. + */ +const caEnv = process.env.DATABASE_CA_CERT; +const ca = caEnv + ? caEnv.includes('BEGIN CERTIFICATE') + ? caEnv + : readFileSync(isAbsolute(caEnv) ? caEnv : resolve(process.cwd(), caEnv), 'utf8') + : undefined; + export default defineConfig({ schema: './src/schema/index.ts', out: './drizzle', dialect: 'postgresql', - dbCredentials: { url: process.env.DATABASE_URL! }, + dbCredentials: { + url: process.env.DATABASE_URL!, + ...(ca ? { ssl: { ca, rejectUnauthorized: true } } : {}), + }, verbose: true, strict: true, }); diff --git a/packages/db/package.json b/packages/db/package.json index b67b7f1..efa8de7 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/db", + "name": "@linkdr/db", "version": "0.0.0", "private": true, "type": "module", @@ -17,13 +17,15 @@ "seed": "tsx src/seed.ts", "recompute-stats": "tsx src/recompute-stats.ts", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "assets:migrate": "tsx src/migrate-assets.ts" }, "dependencies": { - "@linkder/shared": "workspace:*", + "@linkdr/shared": "workspace:*", "@opentelemetry/api": "1.9.1", "drizzle-orm": "0.38.4", - "postgres": "^3.4.5" + "postgres": "^3.4.5", + "@aws-sdk/client-s3": "^3.717.0" }, "devDependencies": { "dotenv": "^16.4.7", diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 365349d..ed8a84c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema/index'; @@ -13,10 +15,52 @@ import * as schema from './schema/index'; * pool on every hot reload and exhaust Postgres connections within a minute. */ const globalForDb = globalThis as unknown as { - __linkderPool?: postgres.Sql; - __linkderDb?: PostgresJsDatabase; + __linkdrPool?: postgres.Sql; + __linkdrDb?: PostgresJsDatabase; }; +/** + * TLS for a managed database. + * + * A hosted Postgres is reached over the public internet, so `sslmode=require` + * alone is not enough: it encrypts the connection but verifies nothing, which + * leaves it open to anyone who can answer for the hostname. Handing the + * provider's CA to the client turns that into a checked identity. + * + * `DATABASE_CA_CERT` takes either a path to the .crt or the certificate inline + * — a path locally where the file is on disk, the PEM itself on a platform + * where secrets are environment variables and there is no filesystem to put it + * on. Unset means a plain connection, which is what local Docker wants. + */ +/** + * Find the certificate file from wherever the caller happens to be. + * + * The scripts run from `packages/db`, Next runs from `apps/web`, and the cert + * sits at the repo root — so a relative path resolved against `process.cwd()` + * alone is wrong for every one of them. Walk up instead, the same way the + * scripts already reach `../../.env`. + */ +function readCaFile(path: string): string { + if (isAbsolute(path)) return readFileSync(path, 'utf8'); + + let dir = process.cwd(); + for (let up = 0; up < 5; up += 1) { + const candidate = resolve(dir, path); + if (existsSync(candidate)) return readFileSync(candidate, 'utf8'); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error(`DATABASE_CA_CERT points at ${path}, which was not found from ${process.cwd()}`); +} + +export function readSsl(): postgres.Options>['ssl'] { + const ca = process.env.DATABASE_CA_CERT; + if (!ca) return undefined; + + return { ca: ca.includes('BEGIN CERTIFICATE') ? ca : readCaFile(ca), rejectUnauthorized: true }; +} + function createPool(): postgres.Sql { const connectionString = process.env.DATABASE_URL; if (!connectionString) { @@ -24,25 +68,28 @@ function createPool(): postgres.Sql { 'DATABASE_URL is not set. Copy .env.example to .env and start Postgres with `pnpm services:up`.', ); } + + const ssl = readSsl(); return postgres(connectionString, { max: Number(process.env.DB_POOL_MAX ?? 10), idle_timeout: 20, + ...(ssl ? { ssl } : {}), }); } export function getPool(): postgres.Sql { - const existing = globalForDb.__linkderPool; + const existing = globalForDb.__linkdrPool; if (existing) return existing; const created = createPool(); - globalForDb.__linkderPool = created; + globalForDb.__linkdrPool = created; return created; } function getDb(): PostgresJsDatabase { - const existing = globalForDb.__linkderDb; + const existing = globalForDb.__linkdrDb; if (existing) return existing; const created = drizzle(getPool(), { schema }); - globalForDb.__linkderDb = created; + globalForDb.__linkdrDb = created; return created; } @@ -82,11 +129,11 @@ export const db: Db = new Proxy({} as Db, { /** Close the pool. For scripts and test teardown — never call this from a request. */ export async function closePool(): Promise { - const existing = globalForDb.__linkderPool; + const existing = globalForDb.__linkdrPool; if (!existing) return; await existing.end(); - globalForDb.__linkderPool = undefined; - globalForDb.__linkderDb = undefined; + globalForDb.__linkdrPool = undefined; + globalForDb.__linkdrDb = undefined; } export { schema }; diff --git a/packages/db/src/migrate-assets.ts b/packages/db/src/migrate-assets.ts new file mode 100644 index 0000000..429279f --- /dev/null +++ b/packages/db/src/migrate-assets.ts @@ -0,0 +1,175 @@ +/** + * Pull every externally-hosted image into our own bucket. + * + * pnpm assets:migrate + * + * The seed used to point at pravatar, picsum and Unsplash. That is fine for a + * scratch database and wrong for anything anybody is shown: those services rate + * limit, change what a URL returns, and go down — and when they do, a demo is a + * grid of broken images with no way to fix it in the moment. + * + * This fetches each one once, stores it under a DETERMINISTIC key, and rewrites + * the row to our own origin. Idempotent: a key that already exists is left + * alone, so re-running after a reseed costs one HEAD per object rather than + * re-downloading the internet. + */ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import * as schema from './schema/index'; +import { readSsl } from './client'; + +for (const line of readFileSync(new URL('../../../.env', import.meta.url), 'utf8').split('\n')) { + const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); + if (m?.[1]) process.env[m[1]] ??= m[2]; +} + +const url = process.env.DATABASE_URL; +if (!url) throw new Error('DATABASE_URL is not set'); + +const region = process.env.SPACES_REGION; +const bucket = process.env.SPACES_BUCKET; +if (!region || !bucket || !process.env.SPACES_KEY || !process.env.SPACES_SECRET) { + throw new Error('Spaces is not configured. Set SPACES_REGION / BUCKET / KEY / SECRET.'); +} + +const origin = process.env.SPACES_CDN_URL || `https://${bucket}.${region}.digitaloceanspaces.com`; + +const client = new S3Client({ + region, + endpoint: `https://${region}.digitaloceanspaces.com`, + credentials: { + accessKeyId: process.env.SPACES_KEY, + secretAccessKey: process.env.SPACES_SECRET, + }, +}); + +const ssl = readSsl(); +const pg = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) }); +const db = drizzle(pg, { schema }); + +const EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/avif': 'avif', +}; + +/** Already ours? Then there is nothing to fetch. */ +function isLocal(u: string): boolean { + return u.startsWith(origin); +} + +async function exists(key: string): Promise { + try { + await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); + return true; + } catch { + return false; + } +} + +/** + * Fetch one image and store it, returning the URL it now lives at. + * + * The key is a hash of the SOURCE url, so the same source always lands on the + * same object: reseeding gives every pro a new uuid, and keying on that would + * fill the bucket with a fresh copy of every photo on every run. + */ +async function adopt(source: string, prefix: string): Promise { + const hash = createHash('sha1').update(source).digest('hex').slice(0, 16); + + const response = await fetch(source, { redirect: 'follow' }); + if (!response.ok) { + console.warn(` ! ${response.status} ${source.slice(0, 60)}`); + return null; + } + + const type = (response.headers.get('content-type') ?? 'image/jpeg').split(';')[0]!.trim(); + const key = `${prefix}/${hash}.${EXT[type] ?? 'jpg'}`; + + if (await exists(key)) return `${origin}/${key}`; + + const body = Buffer.from(await response.arrayBuffer()); + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: type, + // Public: these are the photos on the deck card. Credential documents are + // a different prefix and are never given an ACL. + ACL: 'public-read', + // A year. The key is content-addressed by source, so a changed image is a + // changed key rather than a stale cache. + CacheControl: 'public, max-age=31536000, immutable', + }), + ); + + console.log(` + ${(body.length / 1024).toFixed(0)}kb ${key}`); + return `${origin}/${key}`; +} + +async function main() { + console.log(`Migrating assets into ${origin}\n`); + + const media = await db + .select({ id: schema.proMedia.id, url: schema.proMedia.url, kind: schema.proMedia.kind }) + .from(schema.proMedia); + + const external = media.filter((m) => !isLocal(m.url)); + console.log(`pro_media: ${media.length} rows, ${external.length} still external`); + + // Sequential on purpose. Twenty parallel fetches against a free image host is + // how you get rate limited half way through and end up with a bucket that is + // partly migrated and rows that disagree with it. + let moved = 0; + for (const row of external) { + const hosted = await adopt(row.url, row.kind === 'photo' ? 'pro-media' : 'work-samples'); + if (!hosted) continue; + await db + .update(schema.proMedia) + .set({ url: hosted }) + .where(sql`${schema.proMedia.id} = ${row.id}`); + moved += 1; + } + + // Job photos are uploaded by customers and already ours, but a seeded one + // could point outward too. + const jobs = await db + .select({ id: schema.jobs.id, photos: schema.jobs.photos }) + .from(schema.jobs); + + let jobPhotos = 0; + for (const job of jobs) { + const outward = job.photos.filter((p) => !isLocal(p)); + if (outward.length === 0) continue; + + const rewritten: string[] = []; + for (const photo of job.photos) { + rewritten.push(isLocal(photo) ? photo : ((await adopt(photo, 'job-photos')) ?? photo)); + } + await db + .update(schema.jobs) + .set({ photos: rewritten }) + .where(sql`${schema.jobs.id} = ${job.id}`); + jobPhotos += outward.length; + } + + console.log(`\n${moved} pro images and ${jobPhotos} job photos now served from ${origin}`); + + const left = ( + await db.select({ url: schema.proMedia.url }).from(schema.proMedia) + ).filter((m) => !isLocal(m.url)); + if (left.length) console.warn(`${left.length} still external — rerun to retry.`); +} + +await main(); +await pg.end(); diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 9efb0cc..cdeb6e9 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -3,13 +3,17 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import { sql } from 'drizzle-orm'; import postgres from 'postgres'; +import { readSsl } from './client'; config({ path: '../../.env' }); const url = process.env.DATABASE_URL; if (!url) throw new Error('DATABASE_URL is not set'); -const client = postgres(url, { max: 1 }); +// Same TLS rule as the app — a managed database is reached over the internet, +// and a migration is the last thing that should run unverified. +const ssl = readSsl(); +const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) }); const db = drizzle(client); // PostGIS must exist before any migration that declares a geography column. diff --git a/packages/db/src/queries/deck.ts b/packages/db/src/queries/deck.ts index 238905d..fe19df7 100644 --- a/packages/db/src/queries/deck.ts +++ b/packages/db/src/queries/deck.ts @@ -1,5 +1,5 @@ import { sql } from 'drizzle-orm'; -import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkder/shared'; +import { DECK_PAGE_SIZE, score, type RankingInput } from '@linkdr/shared'; import type { Db } from '../client'; import { eligiblePro } from './eligibility'; diff --git a/packages/db/src/queries/search.ts b/packages/db/src/queries/search.ts index 576d6e9..26f8f37 100644 --- a/packages/db/src/queries/search.ts +++ b/packages/db/src/queries/search.ts @@ -1,5 +1,5 @@ import { sql, type SQL } from 'drizzle-orm'; -import { score, type RankingInput } from '@linkder/shared'; +import { score, type RankingInput } from '@linkdr/shared'; import type { Db } from '../client'; import { eligiblePro } from './eligibility'; import type { DeckCard } from './deck'; diff --git a/packages/db/src/queries/stats.ts b/packages/db/src/queries/stats.ts index fc1e09b..3168ed3 100644 --- a/packages/db/src/queries/stats.ts +++ b/packages/db/src/queries/stats.ts @@ -5,7 +5,7 @@ import type { Db } from '../client'; * Recompute the denormalised ranking counters on `pro_profiles`. * * `rating_avg`, `rating_count`, `completed_jobs`, `response_rate` and - * `avg_response_minutes` are inputs to `score()` in @linkder/shared, and until + * `avg_response_minutes` are inputs to `score()` in @linkdr/shared, and until * this existed nothing ever wrote them after the seed. The deck ranked on * numbers that were invented once and never moved, and the card told customers * "usually replies in 25 min" on the strength of it. diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 1196394..23708cb 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -9,7 +9,7 @@ import { unique, uuid, } from 'drizzle-orm/pg-core'; -import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; +import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared'; import { point } from '../postgis'; import { locationPrecision, userRole } from './enums'; @@ -38,7 +38,7 @@ export const users = pgTable( /** * Required and unique by better-auth. Phone-first users get a synthetic * address on a domain we control — ALWAYS gate outbound mail on - * `isSyntheticEmail()` from @linkder/shared. Pros must supply a real + * `isSyntheticEmail()` from @linkdr/shared. Pros must supply a real * address during onboarding; clients may never have one. */ email: text('email').notNull().unique(), @@ -71,7 +71,7 @@ export const users = pgTable( * "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. */ + /** Human label for `location` — "Condesa, Ciudad de México". Display only; never matched on. */ locationText: text('location_text'), /** * See jobs.location_precision. Lowest stakes of the three: this only centres diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index fcc888d..7a33db7 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -7,10 +7,10 @@ import { QUOTE_STATUSES, REQUEST_STATUSES, VERIFICATION_STATUSES, -} from '@linkder/shared'; +} from '@linkdr/shared'; /** - * Enums mirror the status unions in @linkder/shared/state-machines. + * Enums mirror the status unions in @linkdr/shared/state-machines. * Importing them here means a new status cannot be added to the DB without * also being added to the transition graph. */ @@ -22,7 +22,7 @@ export const bookingStatus = pgEnum('booking_status', BOOKING_STATUSES); export const paymentStatus = pgEnum('payment_status', PAYMENT_STATUSES); export const verificationStatus = pgEnum('verification_status', VERIFICATION_STATUSES); export const urgency = pgEnum('urgency', ['now', 'this_week', 'flexible']); -/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkder/shared. */ +/** How a stored point was obtained — see LOCATION_PRECISIONS in @linkdr/shared. */ export const locationPrecision = pgEnum('location_precision', LOCATION_PRECISIONS); export const swipeDirection = pgEnum('swipe_direction', ['left', 'right']); export const credentialKind = pgEnum('credential_kind', ['id', 'licence', 'insurance']); diff --git a/packages/db/src/schema/settings.ts b/packages/db/src/schema/settings.ts index c0c57e8..c2bff3b 100644 --- a/packages/db/src/schema/settings.ts +++ b/packages/db/src/schema/settings.ts @@ -9,7 +9,7 @@ import { users } from './auth'; * where an existing user has no preferences. Every column therefore defaults to * the value we would use in the absence of a row. * - * These are read on every send — see `notify()` in @linkder/notify, which maps + * These are read on every send — see `notify()` in @linkdr/notify, which maps * each notification kind to the column that governs it and drops the message * when the answer is false. */ diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index d71d287..9f2d09a 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -10,24 +10,51 @@ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; -import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared'; +import { DEFAULT_SERVICE_RADIUS_M } from '@linkdr/shared'; import * as schema from './schema/index'; import { recomputeAllProStats } from './queries/stats'; +import { readSsl } from './client'; config({ path: '../../.env' }); const url = process.env.DATABASE_URL; if (!url) throw new Error('DATABASE_URL is not set'); -const client = postgres(url, { max: 1 }); +// Same TLS rule as the app — the seed truncates and rewrites everything, so +// it is the last thing that should reach a managed database unverified. +const ssl = readSsl(); +const client = postgres(url, { max: 1, ...(ssl ? { ssl } : {}) }); const db = drizzle(client, { schema }); const CITY = { - name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Barcelona', - lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), - lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), + name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'Ciudad de México', + lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), + lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), }; +/** + * Real CDMX streets, rotated by index. + * + * A demo where every job is at "Example Street 12" reads as filler on the very + * screen — the job list — that is meant to look like real work. These are all + * in Roma/Condesa, which is inside the seeded pros' service radii. + */ +const STREETS = [ + 'Av. Álvaro Obregón', + 'Calle Durango', + 'Av. Ámsterdam', + 'Calle Colima', + 'Av. Michoacán', + 'Calle Orizaba', + 'Av. Nuevo León', + 'Calle Tonalá', +]; + +/** "Calle Colima 34, Ciudad de México" — a house number and a street, per index. */ +function streetAddress(number: number, k = 0): string { + return `${STREETS[k % STREETS.length]} ${number}, ${CITY.name}`; +} + /** Move a known distance along a bearing from an origin. Accurate enough at city scale. */ function offset(lat: number, lng: number, metres: number, bearingDeg: number) { const R = 6_371_000; @@ -118,6 +145,19 @@ interface SeedPro { cat: string; /** Free-text specialisms. Search matches these, so a few pros must have some. */ skills?: string[]; + /** + * REQUIRED. The Unsplash photo shown on this pro's card. + * + * The deck is a people-picker: a card is somebody you are deciding whether to + * let into your home, so a stock portrait of a stranger under the word + * "Electrician" reads as a dating app, and a keyword search for "painter" + * returns oil paintings. Both were tried and both were wrong. + * + * Every id below was chosen by reading Unsplash's own written description and + * keeping only those that say a PERSON is doing THAT trade. Verified by text, + * not by luck — so if one looks wrong, the fix is to swap the id here. + */ + photo: string; distanceM: number; rating: number | null; reviews: number; @@ -129,43 +169,51 @@ interface SeedPro { /** distanceM is measured from the city centre — deck tests assert against these. */ const PROS: SeedPro[] = [ - { name: 'Marc Oliveras', cat: 'plumber', distanceM: 800, rating: 4.9, reviews: 47, radius: 15_000, - skills: ['Underfloor heating', 'Emergency callouts', 'Boiler swaps'] }, - { name: 'Ana Ferrer', cat: 'plumber', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000, + { name: 'Antón Bautista', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 2_400, rating: 4.7, reviews: 23, radius: 15_000, skills: ['Bathroom fitting', 'Leak detection'] }, - { name: 'Jordi Puig', cat: 'plumber', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 }, - { name: 'Nuria Sala', cat: 'plumber', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 }, + { name: 'Jorge Pineda', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 6_500, rating: 4.5, reviews: 12, radius: 10_000 }, + { name: 'Norma Salgado', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 18_000, rating: 5.0, reviews: 3, radius: 25_000 }, // Further away than they are willing to travel — must NOT appear for a central job. - { name: 'Pau Ribas', cat: 'plumber', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 }, - { name: 'Laia Mestre', cat: 'electrician', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000, + { name: 'Pablo Rivas', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 22_000, rating: 4.8, reviews: 31, radius: 5_000 }, + { name: 'Martino Gómez', cat: 'electrician', photo: 'photo-1621905251189-08b45d6a269e', distanceM: 1_200, rating: 4.8, reviews: 56, radius: 20_000, skills: ['EV chargers', 'Rewiring', 'Fuse boards'] }, - { name: 'Oriol Camps', cat: 'electrician', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 }, - { name: 'Marta Vidal', cat: 'electrician', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 }, - { name: 'Sergi Bonet', cat: 'handyman', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 }, - { name: 'Clara Roca', cat: 'handyman', distanceM: 5_200, rating: 4.7, reviews: 34, radius: 18_000 }, - { name: 'Ivan Serra', cat: 'handyman', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 }, - { name: 'Elena Prat', cat: 'painter', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 }, - { name: 'Toni Blanch', cat: 'painter', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 }, - { name: 'Rosa Ventura', cat: 'carpenter', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000, + { name: 'Omar Campos', cat: 'electrician', photo: 'photo-1660330589693-99889d60181e', distanceM: 3_900, rating: 4.6, reviews: 18, radius: 15_000 }, + { name: 'Marta Villanueva', cat: 'electrician', photo: 'photo-1646640381839-02748ae8ddf0', distanceM: 9_100, rating: 4.9, reviews: 71, radius: 30_000 }, + { name: 'Sergio Bonilla', cat: 'handyman', photo: 'photo-1621905251918-48416bd8575a', distanceM: 1_800, rating: 4.4, reviews: 9, radius: 12_000 }, + { name: 'Iván Serrano', cat: 'handyman', photo: 'photo-1698998882494-57c3e043f340', distanceM: 11_500, rating: 4.2, reviews: 6, radius: 15_000 }, + { name: 'Elena Prado', cat: 'painter', photo: 'photo-1717281234297-3def5ae3eee1', distanceM: 2_100, rating: 4.9, reviews: 28, radius: 20_000 }, + { name: 'Antonio Blanco', cat: 'painter', photo: 'photo-1652829069834-2c05031199c5', distanceM: 7_800, rating: 4.3, reviews: 15, radius: 15_000 }, + { name: 'Rosa Ventura', cat: 'carpenter', photo: 'photo-1659930087003-2d64e33181f7', distanceM: 3_300, rating: 4.8, reviews: 41, radius: 25_000, skills: ['Fitted wardrobes', 'Listed buildings'] }, - { name: 'Guillem Costa', cat: 'carpenter', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 }, - { name: 'Silvia Marti', cat: 'locksmith', distanceM: 950, rating: 4.7, reviews: 62, radius: 25_000 }, - { name: 'Xavi Duran', cat: 'locksmith', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 }, - { name: 'Berta Lloret', cat: 'appliance-repair', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 }, - { name: 'Adria Font', cat: 'appliance-repair', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 }, - { name: 'Carla Ripoll', cat: 'hvac', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000, + { name: 'Guillermo Cortés', cat: 'carpenter', photo: 'photo-1544164560-adac3045edb2', distanceM: 8_600, rating: 4.6, reviews: 19, radius: 15_000 }, + { name: 'Javier Durán', cat: 'locksmith', photo: 'photo-1676630656246-3047520adfdf', distanceM: 4_400, rating: 4.5, reviews: 22, radius: 20_000 }, + { name: 'Berta Loera', cat: 'appliance-repair', photo: 'photo-1698998882494-57c3e043f340', distanceM: 2_700, rating: 4.8, reviews: 37, radius: 18_000 }, + { name: 'Adrián Fonseca', cat: 'appliance-repair', photo: 'photo-1621905251918-48416bd8575a', distanceM: 10_200, rating: 4.4, reviews: 11, radius: 20_000 }, + { name: 'Carlo Rincón', cat: 'hvac', photo: 'photo-1642749776312-aa42ce20c9f5', distanceM: 3_100, rating: 4.9, reviews: 44, radius: 22_000, skills: ['Split units', 'Heat pumps'] }, - { name: 'Marc Segura', cat: 'hvac', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 }, + { name: 'Marco Segura', cat: 'hvac', photo: 'photo-1705579605238-24a90c8799c5', distanceM: 12_800, rating: 4.6, reviews: 27, radius: 25_000 }, // Brand new and unrated — proves the new-pro boost keeps fresh supply visible. - { name: 'Nil Bosch', cat: 'plumber', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true }, - { name: 'Julia Camps', cat: 'electrician', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true }, + { name: 'Néstor Bosque', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_500, rating: null, reviews: 0, radius: 15_000, isNew: true }, + // Five more plumbers with real trade photography, so the first deck a client + // sees is deep and looks like the job it is for. + { name: 'Sergio Fabela', cat: 'plumber', photo: 'photo-1676210133055-eab6ef033ce3', distanceM: 1_100, rating: 4.8, reviews: 62, radius: 18_000, + skills: ['Underfloor heating', 'Emergency callouts', 'Blocked drains', 'Pipe relining'] }, + { name: 'Rogelio Amaya', cat: 'plumber', photo: 'photo-1676210134190-3f2c0d5cf58d', distanceM: 2_900, rating: 4.6, reviews: 38, radius: 20_000, + skills: ['Boiler servicing', 'Radiator installs'] }, + { name: 'Gerardo Solís', cat: 'plumber', photo: 'photo-1621905252507-b35492cc74b4', distanceM: 4_200, rating: 4.9, reviews: 84, radius: 15_000, + skills: ['Bathroom refits', 'Underfloor heating', 'Wet rooms'] }, + { name: 'Alejandro Dávila', cat: 'plumber', photo: 'photo-1659353588842-891391e6fcd4', distanceM: 5_600, rating: 4.4, reviews: 17, radius: 12_000, + skills: ['Leak detection', 'Tap and valve repairs'] }, + { name: 'Gabriel Torres', cat: 'plumber', photo: 'photo-1558618666-fcd25c85cd64', distanceM: 7_300, rating: 4.7, reviews: 29, radius: 25_000, + skills: ['Water heaters', 'Kitchen plumbing', 'Emergency callouts'] }, + { name: 'Julia Cázares', cat: 'electrician', photo: 'photo-1758101755915-462eddc23f57', distanceM: 2_200, rating: null, reviews: 0, radius: 15_000, isNew: true }, // Not verified — must never reach a deck. - { name: 'Unverified Ulla', cat: 'plumber', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true }, + { name: 'Unverified Ulises', cat: 'plumber', photo: 'photo-1749532125405-70950966b0e5', distanceM: 1_000, rating: null, reviews: 0, radius: 15_000, unverified: true }, // Verified but on holiday — must never reach a deck. - { name: 'Away Arnau', cat: 'plumber', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true }, + { name: 'Away Arturo', cat: 'plumber', photo: 'photo-1676210134188-4c05dd172f89', distanceM: 1_100, rating: 4.9, reviews: 20, radius: 15_000, away: true }, ]; -const CLIENTS = ['Sofia Grau', 'Daniel Miro', 'Emma Rovira', 'Lucas Pons', 'Alba Torres']; +const CLIENTS = ['Sofía Guzmán', 'Daniel Miranda', 'Emma Rivera', 'Lucas Ponce', 'Alba Tovar']; /** * Finished work, per trade, for the review histories below. @@ -282,7 +330,7 @@ const GENERIC_WORK: SeedWork[] = [ * * Built to AVERAGE to the pro's headline figure rather than scattered around * it, because the counters are derived from these rows: deck.test.ts asserts - * Marc Oliveras rates 4.9, and that now has to come out of 47 individual + * Sergi Fabra rates 4.8, and that now has to come out of 62 individual * scores rather than being asserted directly on the profile. * * So a 4.9 becomes forty-two 5s and five 4s. Whole stars only — nobody awards @@ -333,7 +381,7 @@ async function main() { * cannot log in as — which makes the seed data invisible in the app it * exists to fill. */ - phoneNumber: i === 0 ? '+34600000000' : `+3460000${String(i + 1).padStart(4, '0')}`, + phoneNumber: i === 0 ? '+525500000000' : `+52550000${String(i + 1).padStart(4, '0')}`, role: 'client' as const, emailVerified: true, phoneNumberVerified: true, @@ -344,9 +392,9 @@ async function main() { console.log(` ${clientRows.length} clients`); await db.insert(schema.users).values({ - name: 'Linkder Admin', + name: 'Linkdr Admin', email: 'admin@linkder.test', - phoneNumber: '+34600009999', + phoneNumber: '+525500009999', role: 'admin', emailVerified: true, }); @@ -364,7 +412,7 @@ async function main() { .values({ name: p.name, email: `pro${i + 1}@linkder.test`, - phoneNumber: `+3461000${String(i + 1).padStart(4, '0')}`, + phoneNumber: `+52551000${String(i + 1).padStart(4, '0')}`, role: 'pro' as const, emailVerified: true, phoneNumberVerified: true, @@ -406,18 +454,21 @@ async function main() { await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId }); proRows.push({ id: user.id, seed: p, 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; + /* + * The card photo, and a wider crop of the same shot as the work sample. + * + * Seeded with the ORIGINAL Unsplash url, then rewritten to our own bucket by + * `pnpm assets:migrate`. Keeping the source here rather than a hardcoded + * Spaces url means the seed still works on a machine with no bucket, and the + * migration stays the one place that knows where assets live. + */ + const card = `https://images.unsplash.com/${p.photo}?w=800&h=1000&fit=crop`; await db.insert(schema.proMedia).values([ - { proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 }, + { proId: user.id, url: card, position: 0 }, { // The second slot is genuinely for work: scenery is fine here. proId: user.id, - url: `https://picsum.photos/seed/${slug}-work/800/1000`, + url: `https://images.unsplash.com/${p.photo}?w=1200&h=900&fit=crop`, kind: 'work_sample' as const, position: 1, }, @@ -494,7 +545,7 @@ async function main() { urgency: 'flexible' as const, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, - addressText: `Carrer Example ${10 + (k % 40)}, ${CITY.name}`, + addressText: streetAddress(10 + (k % 40), k), status: 'completed' as const, createdAt: new Date(at(k).getTime() - 6 * 86_400_000), }; @@ -510,7 +561,7 @@ async function main() { urgency: 'this_week' as const, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, - addressText: `Carrer Example ${60 + (k % 20)}, ${CITY.name}`, + addressText: streetAddress(60 + (k % 20), k + 3), status: 'cancelled' as const, createdAt: new Date(at(n + k).getTime() - 6 * 86_400_000), })), @@ -623,7 +674,7 @@ async function main() { budgetMaxCents: 20_000, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, - addressText: `Carrer Example 12, ${CITY.name}`, + addressText: streetAddress(12), }) .returning(); console.log(` 1 open job at the city centre (${job?.id})`); @@ -679,7 +730,7 @@ async function main() { urgency: 'this_week' as const, location: { lat: CITY.lat, lng: CITY.lng }, locationPrecision: 'exact' as const, - addressText: `Carrer Example 12, ${CITY.name}`, + addressText: streetAddress(12), status: c.status, }) .returning(); diff --git a/packages/db/test/deck.test.ts b/packages/db/test/deck.test.ts index 42ad84f..1d8dd68 100644 --- a/packages/db/test/deck.test.ts +++ b/packages/db/test/deck.test.ts @@ -2,7 +2,7 @@ * Integration test — runs against a live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/db test + * pnpm --filter @linkdr/db test * * The seed places every pro at a known distance from the city centre, and the * fixture job sits exactly at the centre, so the expected deck is not "roughly @@ -52,23 +52,34 @@ describe('getDeck', () => { const deck = await getDeck(db, { jobId }); const names = deck.map((c) => c.name).sort(); - expect(names).toEqual(['Ana Ferrer', 'Jordi Puig', 'Marc Oliveras', 'Nil Bosch', 'Nuria Sala']); + expect(names).toEqual([ + 'Alejandro Dávila', + 'Antón Bautista', + 'Gabriel Torres', + 'Gerardo Solís', + 'Jorge Pineda', + // Sorted by code unit, so accented letters land after plain ASCII ones. + 'Norma Salgado', + 'Néstor Bosque', + 'Rogelio Amaya', + 'Sergio Fabela', + ]); }); it('excludes a pro whose service radius does not reach the job', async () => { - // Pau Ribas is 22km away but only travels 5km. + // Pablo Rivas is 22km away but only travels 5km. const deck = await getDeck(db, { jobId }); - expect(deck.map((c) => c.name)).not.toContain('Pau Ribas'); + expect(deck.map((c) => c.name)).not.toContain('Pablo Rivas'); }); it('excludes an unverified pro even though they are 1km away', async () => { const deck = await getDeck(db, { jobId }); - expect(deck.map((c) => c.name)).not.toContain('Unverified Ulla'); + expect(deck.map((c) => c.name)).not.toContain('Unverified Ulises'); }); it('excludes a verified pro who is not accepting jobs', async () => { const deck = await getDeck(db, { jobId }); - expect(deck.map((c) => c.name)).not.toContain('Away Arnau'); + expect(deck.map((c) => c.name)).not.toContain('Away Arturo'); }); it('excludes pros from other trades', async () => { @@ -80,32 +91,32 @@ describe('getDeck', () => { it('reports distance in metres, ascending-ish and sane', async () => { const deck = await getDeck(db, { jobId }); - const marc = deck.find((c) => c.name === 'Marc Oliveras'); - expect(marc).toBeDefined(); - expect(marc!.distanceM).toBeGreaterThan(700); - expect(marc!.distanceM).toBeLessThan(900); + const sergi = deck.find((c) => c.name === 'Sergio Fabela'); + expect(sergi).toBeDefined(); + expect(sergi!.distanceM).toBeGreaterThan(1_000); + expect(sergi!.distanceM).toBeLessThan(1_200); }); it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => { const deck = await getDeck(db, { jobId }); - const marc = deck.findIndex((c) => c.name === 'Marc Oliveras'); // 800m, 4.9 x47 - const nuria = deck.findIndex((c) => c.name === 'Nuria Sala'); // 18km, 5.0 x3 - expect(marc).toBeLessThan(nuria); + const sergi = deck.findIndex((c) => c.name === 'Sergio Fabela'); // 1.1km, 4.8 x62 + const nuria = deck.findIndex((c) => c.name === 'Norma Salgado'); // 18km, 5.0 x3 + expect(sergi).toBeLessThan(nuria); }); it('does not bury a brand-new unrated pro at the bottom', async () => { const deck = await getDeck(db, { jobId }); - const nil = deck.findIndex((c) => c.name === 'Nil Bosch'); + const nil = deck.findIndex((c) => c.name === 'Néstor Bosque'); expect(nil).toBeGreaterThanOrEqual(0); expect(nil).toBeLessThan(deck.length - 1); }); it('carries the media and rating a card needs to render', async () => { const deck = await getDeck(db, { jobId }); - const card = deck.find((c) => c.name === 'Marc Oliveras')!; + const card = deck.find((c) => c.name === 'Sergio Fabela')!; expect(card.photos.length).toBeGreaterThan(0); - expect(card.ratingAvg).toBeCloseTo(4.9, 1); - expect(card.ratingCount).toBe(47); + expect(card.ratingAvg).toBeCloseTo(4.8, 1); + expect(card.ratingCount).toBe(62); expect(card.hourlyRateCents).toBeGreaterThan(0); }); @@ -184,8 +195,8 @@ describe('PostGIS round-trip', () => { it('reads back the exact coordinates it wrote', async () => { const rows = await db.select().from(schema.jobs).limit(1); const job = rows[0]!; - expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), 4); - expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 4); + expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), 4); + expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), 4); }); it('never puts the client on their own deck', async () => { diff --git a/packages/db/test/search.test.ts b/packages/db/test/search.test.ts index 9d6e237..b3215c1 100644 --- a/packages/db/test/search.test.ts +++ b/packages/db/test/search.test.ts @@ -2,7 +2,7 @@ * Integration test — runs against a live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/db test + * pnpm --filter @linkdr/db test * * Search is a second door onto the same supply as the deck, so the test that * matters most is the parity one: a pro who can be found here must be a pro who @@ -19,7 +19,10 @@ const { searchPros } = await import('../src/queries/search'); const { getShowcaseDeck } = await import('../src/queries/deck'); /** The seed places every pro relative to this point. */ -const CENTRE = { lat: 41.3874, lng: 2.1686 }; +const CENTRE = { + lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), + lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), +}; let plumberId: string; @@ -31,17 +34,17 @@ beforeAll(async () => { plumberId = plumber.id; // Seeded skills are empty, so text search has nothing to match until we give - // one pro something to find. Marc Oliveras is 800 m from the centre. + // one pro something to find. Sergio Fabela is 1.1 km from the centre. await db.execute(sql` UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts'] - WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') + WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela') `); }); afterAll(async () => { await db.execute(sql` UPDATE pro_profiles SET skills = '{}'::text[] - WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') + WHERE user_id = (SELECT id FROM users WHERE name = 'Sergio Fabela') `); await closePool(); }); @@ -66,17 +69,17 @@ describe('searchPros', () => { it('excludes the unverified, the away and the too-far', async () => { const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name); - expect(names).not.toContain('Unverified Ulla'); - expect(names).not.toContain('Away Arnau'); - expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius + expect(names).not.toContain('Unverified Ulises'); + expect(names).not.toContain('Away Arturo'); + expect(names).not.toContain('Pablo Rivas'); // 22 km out, 5 km radius }); it('matches a skill', async () => { const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map( (p) => p.name, ); - expect(names).toContain('Marc Oliveras'); - expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill + expect(names).toContain('Sergio Fabela'); + expect(names).not.toContain('Martino Gómez'); // an electrician with no such skill }); it('matches a trade name', async () => { @@ -86,8 +89,8 @@ describe('searchPros', () => { }); it('matches a pro by name', async () => { - const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name); - expect(names).toEqual(['Marc Oliveras']); + const names = (await searchPros(db, { ...CENTRE, q: 'fabela', limit: 50 })).map((p) => p.name); + expect(names).toEqual(['Sergio Fabela']); }); it('treats wildcards as literal characters', async () => { @@ -105,7 +108,7 @@ describe('searchPros', () => { it("honours the searcher's own distance limit", async () => { const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 }); expect(near.every((p) => p.distanceM <= 3_000)).toBe(true); - expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out + expect(near.map((p) => p.name)).not.toContain('Marta Villanueva'); // 9.1 km out }); it('sorts by distance, price and rating', async () => { diff --git a/packages/db/test/showcase.test.ts b/packages/db/test/showcase.test.ts index 83f5e9d..6fe3d29 100644 --- a/packages/db/test/showcase.test.ts +++ b/packages/db/test/showcase.test.ts @@ -2,7 +2,7 @@ * Integration test — runs against a live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed - * pnpm --filter @linkder/db test + * pnpm --filter @linkdr/db test * * getShowcaseDeck feeds the entry screen, which is the one deck an anonymous * visitor sees. Its whole promise is the word "verified": nobody may appear in @@ -19,7 +19,10 @@ const { closePool, db } = await import('../src/client'); const { getShowcaseDeck } = await import('../src/queries/deck'); /** The seed places the fixture job, and every distance, relative to this point. */ -const CENTRE = { lat: 41.3874, lng: 2.1686 }; +const CENTRE = { + lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 19.4326), + lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), +}; let names: (string | null)[]; @@ -38,23 +41,23 @@ describe('getShowcaseDeck', () => { }); it('excludes a pro who will not travel this far', () => { - // Pau Ribas is seeded 22 km out with a 5 km service radius. - expect(names).not.toContain('Pau Ribas'); + // Pablo Rivas is seeded 22 km out with a 5 km service radius. + expect(names).not.toContain('Pablo Rivas'); }); it('excludes a pro whose verification has not passed', () => { - // Unverified Ulla is 1 km away — close enough to prove distance is not + // Unverified Ulises is 1 km away — close enough to prove distance is not // what is keeping her out. - expect(names).not.toContain('Unverified Ulla'); + expect(names).not.toContain('Unverified Ulises'); }); it('excludes a verified pro who is not accepting work', () => { - // Away Arnau is verified and nearby, but on holiday mode. - expect(names).not.toContain('Away Arnau'); + // Away Arturo is verified and nearby, but on holiday mode. + expect(names).not.toContain('Away Arturo'); }); it('includes the nearest eligible pro', () => { - expect(names).toContain('Marc Oliveras'); + expect(names).toContain('Sergio Fabela'); }); it('honours the limit', async () => { @@ -63,13 +66,13 @@ describe('getShowcaseDeck', () => { }); 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 + // Marta Villanueva 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(nearNames).not.toContain('Marta Villanueva'); + expect(nearNames).toContain('Sergio Fabela'); // 1.1 km away expect(near.every((c) => c.distanceM <= 3_000)).toBe(true); }); @@ -97,11 +100,11 @@ describe('getShowcaseDeck', () => { 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 + // Pablo Rivas 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'); + expect(filtered).not.toContain('Pablo Rivas'); + expect(filtered).not.toContain('Unverified Ulises'); + expect(filtered).not.toContain('Away Arturo'); }); it('never returns a card with a distance beyond that pro’s own radius', async () => { diff --git a/packages/geocode/package.json b/packages/geocode/package.json index 3c86938..d1a129a 100644 --- a/packages/geocode/package.json +++ b/packages/geocode/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/geocode", + "name": "@linkdr/geocode", "version": "0.0.0", "private": true, "type": "module", @@ -13,7 +13,7 @@ "test": "vitest run" }, "dependencies": { - "@linkder/shared": "workspace:*", + "@linkdr/shared": "workspace:*", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/geocode/src/index.ts b/packages/geocode/src/index.ts index 3c79dc7..3e3d40e 100644 --- a/packages/geocode/src/index.ts +++ b/packages/geocode/src/index.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; -import type { LatLng, LocationPrecision } from '@linkder/shared'; +import type { LatLng, LocationPrecision } from '@linkdr/shared'; /** * Address → coordinates. * - * Linkder matches on distance: `ST_Distance(p.base_location, j.location)` ranks + * Linkdr matches on distance: `ST_Distance(p.base_location, j.location)` ranks * every deck and `ST_DWithin(..., p.service_radius_m)` decides who is eligible * at all. Before this package both operands were the city centre for any user * who declined the browser's location prompt, so the ranking was ordering by @@ -44,7 +44,7 @@ function readConfig(): GeocodeConfig { throw new GeocodeError('Geocoding is not configured. Missing: MAPBOX_TOKEN'); } // Bounding results to one country is a quality decision, not a security one: - // "Carrer de Sants" matches in several places and the wrong continent is a + // "Av. Juárez" matches in several places and the wrong continent is a // worse answer than no answer. return { token, country: process.env.MAPBOX_COUNTRY ?? 'es' }; } diff --git a/packages/geocode/test/geocode.test.ts b/packages/geocode/test/geocode.test.ts index 4d5d671..f199fe4 100644 --- a/packages/geocode/test/geocode.test.ts +++ b/packages/geocode/test/geocode.test.ts @@ -27,7 +27,7 @@ function feature(overrides: { full_address: overrides.fullAddress, name: overrides.name, place_formatted: overrides.placeFormatted, - coordinates: { longitude: overrides.lng ?? 2.1686, latitude: overrides.lat ?? 41.3874 }, + coordinates: { longitude: overrides.lng ?? -99.1332, latitude: overrides.lat ?? 19.4326 }, }, }; } @@ -66,26 +66,26 @@ describe('parseResponse', () => { feature({ featureType: 'address', id: 'addr-1', - fullAddress: 'Carrer de Sants 12, 08014 Barcelona, Spain', - lat: 41.3751, - lng: 2.1339, + fullAddress: 'Av. Álvaro Obregón 12, 06700 Ciudad de México, Mexico', + lat: 19.4194, + lng: -99.1655, }), ], }); expect(result).toEqual({ providerId: 'addr-1', - label: 'Carrer de Sants 12, 08014 Barcelona, Spain', - coordinates: { lat: 41.3751, lng: 2.1339 }, + label: 'Av. Álvaro Obregón 12, 06700 Ciudad de México, Mexico', + coordinates: { lat: 19.4194, lng: -99.1655 }, precision: 'exact', }); }); it('builds a label from name and place when there is no full address', () => { const [result] = parseResponse({ - features: [feature({ featureType: 'street', name: 'Carrer de Sants', placeFormatted: 'Barcelona, Spain' })], + features: [feature({ featureType: 'street', name: 'Av. Álvaro Obregón', placeFormatted: 'Ciudad de México, Mexico' })], }); - expect(result?.label).toBe('Carrer de Sants, Barcelona, Spain'); + expect(result?.label).toBe('Av. Álvaro Obregón, Ciudad de México, Mexico'); expect(result?.precision).toBe('approximate'); }); diff --git a/packages/notify/package.json b/packages/notify/package.json index 5baef92..a7772d3 100644 --- a/packages/notify/package.json +++ b/packages/notify/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/notify", + "name": "@linkdr/notify", "version": "0.0.0", "private": true, "type": "module", @@ -13,8 +13,8 @@ "test": "vitest run" }, "dependencies": { - "@linkder/db": "workspace:*", - "@linkder/shared": "workspace:*", + "@linkdr/db": "workspace:*", + "@linkdr/shared": "workspace:*", "drizzle-orm": "0.38.4" }, "devDependencies": { diff --git a/packages/notify/src/index.ts b/packages/notify/src/index.ts index 96f9174..18a5cff 100644 --- a/packages/notify/src/index.ts +++ b/packages/notify/src/index.ts @@ -1,6 +1,6 @@ import { eq } from 'drizzle-orm'; -import { schema, type Db } from '@linkder/db'; -import { isContactableEmail } from '@linkder/shared'; +import { schema, type Db } from '@linkdr/db'; +import { isContactableEmail } from '@linkdr/shared'; import { NotConfiguredError, sendEmail, sendSms } from './transport'; export { sendEmail, sendSms, NotConfiguredError } from './transport'; @@ -86,14 +86,14 @@ function render(input: NotifyInput): Message { return { subject: `New ${input.trade} job ${km}km away`, body: - `Linkder: a ${input.trade.toLowerCase()} job ${km}km away is waiting on your answer. ` + + `Linkdr: a ${input.trade.toLowerCase()} job ${km}km away is waiting on your answer. ` + `You have ${input.expiresInHours} hours before it goes to someone else.`, }; } case 'request.accepted': return { subject: `${input.proName} wants your job`, - body: `Linkder: ${input.proName} said yes to "${input.jobTitle}". Open the app to agree a price.`, + body: `Linkdr: ${input.proName} said yes to "${input.jobTitle}". Open the app to agree a price.`, }; case 'verification.submitted': return { @@ -102,13 +102,13 @@ function render(input: NotifyInput): Message { }; case 'verification.approved': return { - subject: 'You are live on Linkder', - body: 'Linkder: you are verified. Customers in your area can see and swipe your card now.', + subject: 'You are live on Linkdr', + body: 'Linkdr: you are verified. Customers in your area can see and swipe your card now.', }; case 'verification.rejected': return { subject: 'We could not approve your account yet', - body: `Linkder: we could not approve your account yet. ${input.notes} Fix it and submit again.`, + body: `Linkdr: we could not approve your account yet. ${input.notes} Fix it and submit again.`, }; } } diff --git a/packages/notify/src/transport.ts b/packages/notify/src/transport.ts index 2142855..e845099 100644 --- a/packages/notify/src/transport.ts +++ b/packages/notify/src/transport.ts @@ -1,4 +1,4 @@ -import { isContactableEmail } from '@linkder/shared'; +import { isContactableEmail } from '@linkdr/shared'; /** * The two ways we can put a message in front of somebody. diff --git a/packages/notify/test/notify.test.ts b/packages/notify/test/notify.test.ts index 5df6cb8..f13476c 100644 --- a/packages/notify/test/notify.test.ts +++ b/packages/notify/test/notify.test.ts @@ -24,7 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); -const { closePool, db } = await import('@linkder/db'); +const { closePool, db } = await import('@linkdr/db'); const { notify } = await import('../src/index'); const RUN = Math.random().toString(36).slice(2, 8); diff --git a/packages/shared/package.json b/packages/shared/package.json index 0cfed56..9adfdba 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/shared", + "name": "@linkdr/shared", "version": "0.0.0", "private": true, "type": "module", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 590f518..89dcd9c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -57,8 +57,8 @@ export const LATE_CANCELLATION_FEE_BPS = 2500; // 25% of the quote /** Default platform commission. Overridden by PLATFORM_FEE_BPS env at runtime. */ 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 +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; diff --git a/packages/shared/src/money.ts b/packages/shared/src/money.ts index f33bcae..c03c202 100644 --- a/packages/shared/src/money.ts +++ b/packages/shared/src/money.ts @@ -39,7 +39,7 @@ export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: C return { fee, payout: amount - fee }; } -export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string { +export function formatCents(amount: Cents, currency = 'USD', locale = 'en-US'): string { return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100); } diff --git a/packages/shared/test/money.test.ts b/packages/shared/test/money.test.ts index 52b895f..5bfd2cc 100644 --- a/packages/shared/test/money.test.ts +++ b/packages/shared/test/money.test.ts @@ -47,7 +47,7 @@ describe('parseAmountToCents', () => { expect(parseAmountToCents('150')).toBe(15_000); expect(parseAmountToCents('150.50')).toBe(15_050); expect(parseAmountToCents('150,50')).toBe(15_050); - expect(parseAmountToCents('€150.50')).toBe(15_050); + expect(parseAmountToCents('$150.50')).toBe(15_050); }); it('rounds to the nearest cent rather than truncating', () => { diff --git a/packages/storage/package.json b/packages/storage/package.json index ed1357d..107e0c2 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,5 +1,5 @@ { - "name": "@linkder/storage", + "name": "@linkdr/storage", "version": "0.0.0", "private": true, "type": "module", @@ -20,6 +20,6 @@ "devDependencies": { "typescript": "^5.7.3", "vitest": "^2.1.8", - "@linkder/shared": "workspace:*" + "@linkdr/shared": "workspace:*" } } diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 71a1c8a..4fc61eb 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -9,11 +9,14 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { z } from 'zod'; /** - * Direct-to-R2 uploads. + * Direct-to-Spaces uploads. + * + * DigitalOcean Spaces, which is S3-compatible — so this is the AWS SDK pointed + * at a different endpoint, and nothing above this file knows the difference. * * Files never pass through the Next server: the browser asks for a presigned - * PUT, uploads straight to R2, then tells us the key. That keeps a 10 MB licence - * scan off the request path and out of the serverless body limit. + * PUT, uploads straight to the bucket, then tells us the key. That keeps a 10 MB + * licence scan off the request path and out of the serverless body limit. * * The security property that matters: the server chooses the key and pins the * content type and length. A client cannot upload a 2 GB file, cannot overwrite @@ -75,7 +78,8 @@ export interface PresignedUpload { export class StorageError extends Error {} interface StorageConfig { - accountId: string; + /** Spaces datacentre, e.g. `nyc3`. Part of both the endpoint and the URL. */ + region: string; accessKeyId: string; secretAccessKey: string; bucket: string; @@ -83,18 +87,16 @@ interface StorageConfig { } function readConfig(): StorageConfig { - const accountId = process.env.R2_ACCOUNT_ID; - const accessKeyId = process.env.R2_ACCESS_KEY_ID; - const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY; - const bucket = process.env.R2_BUCKET; - const publicUrl = process.env.R2_PUBLIC_URL; + const region = process.env.SPACES_REGION; + const accessKeyId = process.env.SPACES_KEY; + const secretAccessKey = process.env.SPACES_SECRET; + const bucket = process.env.SPACES_BUCKET; const missing = Object.entries({ - R2_ACCOUNT_ID: accountId, - R2_ACCESS_KEY_ID: accessKeyId, - R2_SECRET_ACCESS_KEY: secretAccessKey, - R2_BUCKET: bucket, - R2_PUBLIC_URL: publicUrl, + SPACES_REGION: region, + SPACES_KEY: accessKeyId, + SPACES_SECRET: secretAccessKey, + SPACES_BUCKET: bucket, }) .filter(([, v]) => !v) .map(([k]) => k); @@ -102,12 +104,27 @@ function readConfig(): StorageConfig { if (missing.length) { throw new StorageError(`Object storage is not configured. Missing: ${missing.join(', ')}`); } + + /* + * The public origin is DERIVED, not configured. + * + * Spaces serves every bucket at `https://..digitaloceanspaces.com`, + * so a separate env var for it is a second place to be wrong — and the way it + * goes wrong is that objects upload to one bucket and render from another, + * which looks like a broken image rather than a misconfiguration. + * + * SPACES_CDN_URL overrides it for the case that genuinely needs one: the CDN + * endpoint, or a custom domain in front of the bucket. + */ + const publicUrl = + process.env.SPACES_CDN_URL ?? `https://${bucket!}.${region!}.digitaloceanspaces.com`; + return { - accountId: accountId!, + region: region!, accessKeyId: accessKeyId!, secretAccessKey: secretAccessKey!, bucket: bucket!, - publicUrl: publicUrl!.replace(/\/$/, ''), + publicUrl: publicUrl.replace(/\/$/, ''), }; } @@ -117,8 +134,10 @@ function getClient() { if (cached) return cached; const config = readConfig(); const client = new S3Client({ - region: 'auto', - endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, + // Spaces is S3-compatible, so the only difference from AWS is the endpoint. + // The region is real (not `auto`) because it is part of the signature. + region: config.region, + endpoint: `https://${config.region}.digitaloceanspaces.com`, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -128,7 +147,7 @@ function getClient() { return cached; } -/** Extension for a content type. Keys carry one so R2 serves the right thing back. */ +/** Extension for a content type. Keys carry one so Spaces serves the right thing back. */ const EXTENSIONS: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', @@ -169,7 +188,7 @@ export function validateUpload(input: UploadRequest): void { * Sign a one-shot PUT. * * `ContentLength` is signed too, so the client cannot request a small file and - * then push a huge one — R2 rejects a mismatched body. + * then push a huge one — the mismatched body is rejected at the edge. */ export async function createPresignedUpload( input: UploadRequest & { ownerId: string }, diff --git a/packages/storage/test/storage.test.ts b/packages/storage/test/storage.test.ts index 6961530..98f293b 100644 --- a/packages/storage/test/storage.test.ts +++ b/packages/storage/test/storage.test.ts @@ -107,11 +107,11 @@ describe('configuration', () => { beforeEach(() => { resetStorageClient(); for (const k of [ - 'R2_ACCOUNT_ID', - 'R2_ACCESS_KEY_ID', - 'R2_SECRET_ACCESS_KEY', - 'R2_BUCKET', - 'R2_PUBLIC_URL', + 'SPACES_REGION', + 'SPACES_KEY', + 'SPACES_SECRET', + 'SPACES_BUCKET', + 'SPACES_CDN_URL', ]) { delete process.env[k]; } @@ -131,7 +131,7 @@ describe('configuration', () => { contentLength: 100, ownerId: OWNER, }), - ).rejects.toThrow(/R2_ACCOUNT_ID.*R2_ACCESS_KEY_ID/s); + ).rejects.toThrow(/SPACES_REGION.*SPACES_KEY/s); }); it('validates the upload before it complains about configuration', async () => { @@ -166,13 +166,13 @@ describe('key/URL contract with the API', () => { }); it('accepts that key against the credential schema', async () => { - const { credentialSchema } = await import('@linkder/shared'); + const { credentialSchema } = await import('@linkdr/shared'); const key = buildKey('credential', OWNER, 'application/pdf'); expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true); }); it('rejects an empty key rather than storing a dangling reference', async () => { - const { credentialSchema } = await import('@linkder/shared'); + const { credentialSchema } = await import('@linkdr/shared'); expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9d74f1..cf44f54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,19 +23,19 @@ importers: apps/web: dependencies: - '@linkder/api': + '@linkdr/api': specifier: workspace:* version: link:../../packages/api - '@linkder/db': + '@linkdr/db': specifier: workspace:* version: link:../../packages/db - '@linkder/notify': + '@linkdr/notify': specifier: workspace:* version: link:../../packages/notify - '@linkder/shared': + '@linkdr/shared': specifier: workspace:* version: link:../../packages/shared - '@linkder/storage': + '@linkdr/storage': specifier: workspace:* version: link:../../packages/storage '@sentry/nextjs': @@ -120,19 +120,19 @@ importers: packages/api: dependencies: - '@linkder/db': + '@linkdr/db': specifier: workspace:* version: link:../db - '@linkder/geocode': + '@linkdr/geocode': specifier: workspace:* version: link:../geocode - '@linkder/notify': + '@linkdr/notify': specifier: workspace:* version: link:../notify - '@linkder/shared': + '@linkdr/shared': specifier: workspace:* version: link:../shared - '@linkder/storage': + '@linkdr/storage': specifier: workspace:* version: link:../storage '@opentelemetry/api': @@ -163,7 +163,10 @@ importers: packages/db: dependencies: - '@linkder/shared': + '@aws-sdk/client-s3': + specifier: ^3.717.0 + version: 3.1114.0 + '@linkdr/shared': specifier: workspace:* version: link:../shared '@opentelemetry/api': @@ -194,7 +197,7 @@ importers: packages/geocode: dependencies: - '@linkder/shared': + '@linkdr/shared': specifier: workspace:* version: link:../shared zod: @@ -210,10 +213,10 @@ importers: packages/notify: dependencies: - '@linkder/db': + '@linkdr/db': specifier: workspace:* version: link:../db - '@linkder/shared': + '@linkdr/shared': specifier: workspace:* version: link:../shared drizzle-orm: @@ -255,7 +258,7 @@ importers: specifier: ^3.24.1 version: 3.25.76 devDependencies: - '@linkder/shared': + '@linkdr/shared': specifier: workspace:* version: link:../shared typescript: