# 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 job to that pro; the pro accepts; chat, quote, booking, escrow payment and reviews all happen in the app. Web first. The API layer is designed so a React Native app can reuse it verbatim. ## Status **M0 — foundation. Complete and verified.** | Milestone | State | |---|---| | M0 Foundation — monorepo, Postgres+PostGIS, schema, CI, app shell | ✅ done | | M1 Auth & profiles | ⬜ next | | M2 Verification & admin queue | ⬜ | | M3 The deck (jobs, swipes, requests, matches) | 🟡 deck query + swipe UI working; needs auth + tRPC | | M4 Chat & scheduling | ⬜ | | M5 Payments & escrow | ⬜ | | M6 Reviews & ranking | ⬜ | | M7 Launch readiness | ⬜ | ## Quick start ```bash pnpm install cp .env.example .env # ports 5442 / 6389 to avoid clashing with other local stacks pnpm services:up # postgres+postgis and redis in docker pnpm db:migrate pnpm db:seed pnpm dev # http://localhost:3000 ``` The landing page lists the seeded job. Open its deck to swipe. ## Layout ``` apps/web Next.js 15 (App Router) — client, pro and admin UIs apps/worker BullMQ worker (M3+): request expiry, payouts, reminders packages/shared money, state machines, ranking weights, zod schemas — no I/O, fully unit tested packages/db Drizzle schema, migrations, the deck query packages/api tRPC routers (M1) — the contract mobile will reuse packages/ui shared components (M1) ``` ### Where the important decisions live - **`packages/shared/src/state-machines.ts`** — every legal status transition. Mutations must call `assertTransition`; nothing jumps from `scheduled` to `completed` because a payload said so. - **`packages/shared/src/ranking.ts`** — the deck scoring weights. This is the product; expect to tune it weekly against booking conversion. - **`packages/shared/src/money.ts`** — integer cents only. `splitCharge` always sums back to the original amount. - **`packages/db/src/queries/deck.ts`** — the deck query. Filtering runs in Postgres on a GiST index (`ST_DWithin`), ranking runs in JS so the weights stay tunable. ## Testing ```bash pnpm test # everything 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, and the fixture job sits exactly at the centre. So the expected deck is an exact list, not a vague "roughly the nearby ones". The seed deliberately includes pros that must **not** appear: | Pro | Why they must be excluded | |---|---| | Pau Ribas | 22 km away but only travels 5 km | | Unverified Ulla | 1 km away, verification still `pending` | | Away Arnau | verified, but `is_accepting_jobs = false` | ## Notes and gotchas - **PostGIS type generation.** drizzle-kit quotes type names it does not recognise, which turns `geography(Point,4326)` into an invalid quoted identifier. `packages/db/scripts/fix-postgis.mjs` unquotes them and runs automatically as part of `pnpm db:generate`. If you ever run `drizzle-kit generate` directly, run the script afterwards. - **Geography, not geometry.** Distances come back in metres and `ST_DWithin` is correct anywhere without picking a projection per city. Do not replace it with hand-rolled haversine — it will not use the GiST index. - **Ports.** Postgres is on `5442` and Redis on `6389`, not the defaults, so the stack can run alongside other local projects. - **The swipe write is currently a Next server action** (`apps/web/src/app/deck/[jobId]/actions.ts`) and **trusts the caller**. It moves into a tRPC procedure with a real session check in M1/M3. It must not ship as-is. - **`pnpm db:seed` truncates everything.** It is for local and CI only. ## Before taking real money Flagged in the plan, unresolved by design — these are business decisions, not code: 1. **Escrow.** Holding client funds between charge and transfer is escrow-adjacent. Stripe Connect separate charges & transfers is the sanctioned marketplace pattern, but confirm the specific flow, merchant-of-record and VAT/invoicing position for your jurisdiction with Stripe. 2. **Cold start.** 30–50 verified pros must exist in the launch city *before* any client opens the app. An empty deck kills the product on day one. This is the real launch blocker, not code. 3. **Trade liability.** Licence and insurance checks are the legal exposure of the whole business. The admin review queue in M2 is not an afterthought.