Commit Graph
6 Commits
Author SHA1 Message Date
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
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>
2026-08-23 10:56:31 -04:00
serfaandClaude Opus 5 974e312534 M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then
stopped: `quotes`, `bookings` and `reviews` had tables and state machines and
nothing that wrote a row, the entry deck's right swipe was wired to an empty
handler, and every address resolved to the city centre.

Jobs tab and chat
- message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not
  a job — one job with three interested pros is three private conversations.
- Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the
  pros who accepted, and the conversation itself with attachments.

Hiring from the deck
- A right swipe on the entry deck opened nothing. It now resolves "which job?"
  through a sheet — sign in, pick an open job, or post one — and calls the same
  deck.swipe the per-job deck does, so the open-request cap and row lock apply
  exactly once. Swipes are vetoable so closing the sheet returns the card.

Geocoding
- ST_Distance and ST_DWithin rank and filter every deck, and both operands were
  placeholders. Addresses now resolve through Mapbox (permanent=true, which is
  what licenses storing the coordinates), the server resolves points rather than
  trusting client-supplied lat/lng, and every stored point records how it was
  obtained. A `city`-precision base cannot reach the verification queue.

Quote -> booking -> review
- The commercial chain, minus payments. Accepting a quote is the only place a
  booking is created; confirming completion is what unlocks reviews and moves
  the pro's completed_jobs.
- Reviews publish double-blind with no sweeper: each is written with
  published_at already set to its embargo deadline and every read filters
  published_at <= now(), so it publishes itself. The second review pulls both
  forward. A silent counterparty cannot bury a bad review by never replying.

State machine changes, both deliberate
- booked -> matched: a cancelled booking is not a cancelled job.
- scheduled -> awaiting_confirmation: in_progress is optional, so a pro who
  never tapped Start can still say the work is done.

Test suite
- api tests ran files in parallel against one database and failed roughly one
  run in three on whichever file lost the race. Serialised, and three fixtures
  that grabbed "the first client" pinned to the seeded accounts.

Also includes work from a parallel session: admin verification queue, pro
public profile and reviews read path, notification sending, denormalised stats
recompute, search, and observability.

318 tests passing; typecheck and lint clean across 7 packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 06:29:59 -04:00
serfowiandClaude Opus 5 176ba187c8 M1: phone app shell, settings, profile, dev login
Everything now renders inside a phone illustration on the entry screen,
with a five-tab bar. The frame lives in the root layout rather than one
page, so sign-in, onboarding and the job form are inside it too.

- Entry screen is the product running, not a marketing page: a live
  swipeable deck of real verified pros with a trade-filter strip above
  the card. deck.showcase is the only public procedure in that router
  and writes nothing, so an anonymous right swipe reaches no one.

- Settings: notification preferences (new table, defaults returned when
  no row exists), signed-in devices, GDPR export, deletion request.

  Closes the setEmail finding: an unverified address is no longer
  written to users.email, which is UNIQUE -- claiming a stranger's
  address used to block them from ever signing up with Google, and the
  uniqueness error leaked whether an address was registered. Now parked
  in email_change_requests until a token proves ownership.

- Profile: for a pro it leads with their REAL deck card, rendered by the
  same exported <Card> clients swipe, so the two cannot drift. Adds
  pro.previewCard (works at draft/pending, where publicProfile 404s) and
  pro.reorderMedia (photo position 0 is the deck card). Warns before an
  edit that would send a verified pro back for review, rather than after
  it silently drops them off the deck. Clients get a thin profile plus a
  route into pro onboarding -- supply is the launch blocker.

- Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV,
  an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It
  overwrites the stored code rather than skipping verification, so the
  real expiry, attempt cap and single-use consumption still apply.

- Seed uses portrait photos. The cards previously showed picsum stock
  scenery -- a locksmith standing on a railway track.

Fixes found along the way: the card's name rendered ink-950 navy on a
dark photo because globals.css sets h1..h6 colour in @layer base, which
beat the inherited text-white; and the card referenced --color-go-500,
--border and --card, none of which exist, so the SEND JOB stamp had no
colour.

Also adds public/sw.js as a kill-switch: a service worker left
registered on localhost:3000 by a different project was intercepting
this app's chunks.

typecheck, lint clean; 186 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 03:16:27 -04:00
serfowiandClaude Opus 5 582f13fa99 Entry screen is the app: phone frame with a live deck inside
The homepage was a marketing brochure -- a hero paragraph, a "three
steps" explainer and a tag list. It described the gesture in prose
while the actual Tinder deck sat behind /deck/[jobId], reachable only
after signing in AND posting a job. Nobody opening the app ever saw
the product.

Now / renders a phone illustration with the real app running inside
it: the same <Deck>, the same drag physics, real verified pros. On a
phone the bezel collapses and the deck simply fills the viewport --
drawing a picture of a phone on a phone is absurd, and it would eat
the width the cards need.

- Removed the fixed app bar and bottom tab bar. AppShell now renders
  the screen title as an in-flow h1; the bar owned the only h1 on
  every screen, so dropping it silently would have left every page
  headingless. The /jobs "post" action moved from bar chrome into the
  content, since the tab bar was its only other route there.

- getShowcaseDeck(): a deck with no job behind it. getDeck is
  job-scoped (joins jobs for category and location, anti-joins
  swipes), which an anonymous visitor has none of, so this centres on
  the launch city. Eligibility rules are copied verbatim -- nobody may
  appear in the shop window who could not appear on a real deck.

- deck.showcase: the only public procedure in the router. list and
  swipe stay behind clientProcedure. It reads nothing about the caller
  and writes nothing, so a right swipe on the entry screen is purely
  local. No real tradesperson is contacted until a job is posted.

- <Deck> filled a hardcoded 560px desktop box; it now fills its
  container. The card counter moved out from between the two action
  buttons so the thumb zone holds nothing but the two controls.

Verified against the seeded database: 22 eligible pros returned, and
all three seeded traps excluded for the right reason -- Pau Ribas
(22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not
accepting). 7 new integration tests cover exactly that.

Also corrects a label I had written as "Plumbers near you" -- the
showcase deck is not category-filtered and shows every trade.

Includes concurrent edits to the mobile shell, ui/ primitives and
DESIGN.md made outside this session.

typecheck, lint, build clean; 141 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:38:00 -04:00
serfowiandClaude Opus 5 66dd4ac942 M1 (partial): tRPC API layer, storage, and three real fixes
Stands up packages/api so the swipe path stops trusting its caller, and
puts the auth library behind an interface we own.

- packages/api: tRPC v11 with a Session type WE define, not one
  re-exported from an auth library. Swapping providers means rewriting
  one SessionResolver, not touching a router.
- Procedure layers: public / protected / client / pro / verifiedPro /
  admin. Admin routes 404 rather than 403 so they cannot be probed.
- deck router replaces the untrusted server action. Ownership is checked
  on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids
  cannot be enumerated. 23 tests, mostly authorization.
- packages/storage: presigned direct-to-R2 uploads. The server picks the
  key, so a caller can only write under their own user id. 14 tests.

Three defects found and fixed:
- The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did
  not trap getPrototypeOf. Auth adapters dispatch on exactly that check,
  so this would have failed at runtime inside third-party code. Fixed
  and pinned with a regression test.
- The open-request cap was a read-then-write race: concurrent swipes
  could both read 4 and both insert. Now one transaction with the job
  row locked. The cap is checked before the tombstone is written, so a
  rejected swipe leaves no trace and the card stays on the deck.
- superjson was configured in two of the three required places. Without
  the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as
  a raw envelope with no type error to warn you.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:11:07 -04:00
serfowiandClaude Opus 5 19623bcccb M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:32:35 -04:00