The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
105 lines
4.7 KiB
Markdown
105 lines
4.7 KiB
Markdown
# 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.
|