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>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "deletion_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"reason" text,
|
||||
"actioned_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"sms_new_request" boolean DEFAULT true NOT NULL,
|
||||
"sms_booking_reminder" boolean DEFAULT true NOT NULL,
|
||||
"sms_marketing" boolean DEFAULT false NOT NULL,
|
||||
"email_receipts" boolean DEFAULT true NOT NULL,
|
||||
"email_marketing" boolean DEFAULT false NOT NULL,
|
||||
"push_messages" boolean DEFAULT true NOT NULL,
|
||||
"push_requests" boolean DEFAULT true NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "deletion_requests" ADD CONSTRAINT "deletion_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "email_change_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_change_requests_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "email_change_requests" ADD CONSTRAINT "email_change_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "users" ADD COLUMN "location" geography(Point,4326);--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "location_text" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "search_radius_m" integer DEFAULT 15000 NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pro_profiles" ADD COLUMN "skills" text[] DEFAULT '{}'::text[] NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,34 @@
|
||||
"when": 1787252153406,
|
||||
"tag": "0000_material_shadow_king",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1787292806114,
|
||||
"tag": "0001_bouncy_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1787292942440,
|
||||
"tag": "0002_jazzy_mac_gargan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1787295054810,
|
||||
"tag": "0003_amazing_turbo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1787296176203,
|
||||
"tag": "0004_closed_nextwave",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -176,11 +176,40 @@ export async function getDeck(
|
||||
*/
|
||||
export async function getShowcaseDeck(
|
||||
db: Db,
|
||||
args: { lat: number; lng: number; limit?: number; now?: Date },
|
||||
args: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
categoryId?: string;
|
||||
/**
|
||||
* How far the person looking is willing to go, in metres. Applied ON TOP of
|
||||
* each pro's own radius: both sides have to agree to the distance, and a pro
|
||||
* who covers the whole city still should not fill the deck of someone who
|
||||
* said "walking distance only".
|
||||
*/
|
||||
maxDistanceM?: number;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
},
|
||||
): Promise<DeckCard[]> {
|
||||
const limit = args.limit ?? DECK_PAGE_SIZE;
|
||||
const now = args.now ?? new Date();
|
||||
|
||||
// Narrowing to one trade. Omitted means every trade, which is what the entry
|
||||
// screen shows before the visitor has told us what they need.
|
||||
const categoryFilter = args.categoryId
|
||||
? sql`AND EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
WHERE pc.pro_id = p.user_id AND pc.category_id = ${args.categoryId}
|
||||
)`
|
||||
: sql``;
|
||||
|
||||
// Same GiST index, same ST_DWithin — just measured against the searcher's
|
||||
// limit rather than the pro's.
|
||||
const distanceFilter =
|
||||
args.maxDistanceM === undefined
|
||||
? sql``
|
||||
: sql`AND ST_DWithin(p.base_location, centre.g, ${args.maxDistanceM})`;
|
||||
|
||||
const rows = await db.execute<{
|
||||
pro_id: string;
|
||||
name: string | null;
|
||||
@@ -240,6 +269,8 @@ export async function getShowcaseDeck(
|
||||
AND p.is_accepting_jobs = true
|
||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
|
||||
${distanceFilter}
|
||||
${categoryFilter}
|
||||
ORDER BY ST_Distance(p.base_location, centre.g) ASC
|
||||
LIMIT ${CANDIDATE_POOL}
|
||||
`);
|
||||
|
||||
@@ -2,12 +2,15 @@ import { relations } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import { point } from '../postgis';
|
||||
import { userRole } from './enums';
|
||||
|
||||
/**
|
||||
@@ -55,6 +58,23 @@ export const users = pgTable(
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: timestamp('ban_expires', { withTimezone: true }),
|
||||
|
||||
/**
|
||||
* Where this person is, and how far they are willing to look.
|
||||
*
|
||||
* This is the CLIENT-side answer only. A pro's working area is
|
||||
* `pro_profiles.base_location` + `service_radius_m` — that pair is what the
|
||||
* deck query and the verification review both read, so duplicating it here
|
||||
* would give a pro two radii and no way to tell which one matched them to a
|
||||
* job. Settings routes a pro's edit to the profile instead.
|
||||
*
|
||||
* Nullable because nobody is asked for it at signup: a null location means
|
||||
* "we do not know yet", and callers fall back to the city centre.
|
||||
*/
|
||||
location: point('location'),
|
||||
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
|
||||
locationText: text('location_text'),
|
||||
searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M),
|
||||
|
||||
/** Deck ranking penalises dormant pros, so this has to be maintained. */
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(),
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './messaging';
|
||||
export * from './commerce';
|
||||
export * from './reviews';
|
||||
export * from './audit';
|
||||
export * from './settings';
|
||||
|
||||
@@ -37,6 +37,16 @@ export const proProfiles = pgTable(
|
||||
baseLocation: point('base_location').notNull(),
|
||||
serviceRadiusM: integer('service_radius_m').notNull().default(15000),
|
||||
|
||||
/**
|
||||
* Free-text specialisms — "underfloor heating", "emergency callouts".
|
||||
*
|
||||
* Deliberately NOT the trade list: `pro_categories` is the matching key and
|
||||
* is what verification checks a licence against, so it stays a closed set of
|
||||
* rows. These are the pro's own words, for a customer to read, and nothing
|
||||
* matches on them.
|
||||
*/
|
||||
skills: text('skills').array().notNull().default(sql`'{}'::text[]`),
|
||||
|
||||
verificationStatus: verificationStatus('verification_status').notNull().default('draft'),
|
||||
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
||||
suspendedReason: text('suspended_reason'),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { users } from './auth';
|
||||
|
||||
/**
|
||||
* Per-user notification preferences.
|
||||
*
|
||||
* A missing row means "all defaults", so there is no backfill and no window
|
||||
* where an existing user has no preferences. Every column therefore defaults to
|
||||
* the value we would use in the absence of a row.
|
||||
*
|
||||
* NOTE: nothing consumes these yet — the worker that sends the messages arrives
|
||||
* in M4. Until then this stores intent only, and the UI must say so rather than
|
||||
* implying a toggle stops an SMS today.
|
||||
*/
|
||||
export const notificationPreferences = pgTable('notification_preferences', {
|
||||
userId: uuid('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
// SMS — the only channel that reaches a phone-only client, so the transactional
|
||||
// ones default on and the marketing one defaults off.
|
||||
smsNewRequest: boolean('sms_new_request').notNull().default(true),
|
||||
smsBookingReminder: boolean('sms_booking_reminder').notNull().default(true),
|
||||
smsMarketing: boolean('sms_marketing').notNull().default(false),
|
||||
|
||||
// Email — only ever sent to a contactable address; see isSyntheticEmail.
|
||||
emailReceipts: boolean('email_receipts').notNull().default(true),
|
||||
emailMarketing: boolean('email_marketing').notNull().default(false),
|
||||
|
||||
// Push — the native app does not exist yet; kept so the shape is stable.
|
||||
pushMessages: boolean('push_messages').notNull().default(true),
|
||||
pushRequests: boolean('push_requests').notNull().default(true),
|
||||
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A GDPR erasure request.
|
||||
*
|
||||
* Deliberately a request rather than a cascade: bookings, payments and reviews
|
||||
* carry foreign keys and statutory retention periods, so "delete my account"
|
||||
* cannot simply DELETE the user. This records the ask and the promise; a human
|
||||
* actions it. That is honest for a pre-launch product and becomes a liability
|
||||
* the day there are real users, so a real flow must exist before launch.
|
||||
*/
|
||||
export const deletionRequests = pgTable('deletion_requests', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
reason: text('reason'),
|
||||
/** Set when a human has completed the erasure. Null means outstanding. */
|
||||
actionedAt: timestamp('actioned_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({
|
||||
user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
|
||||
user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* A requested — but not yet proven — email address.
|
||||
*
|
||||
* This table exists to keep unverified addresses OUT of `users.email`. Writing
|
||||
* them there directly (the previous behaviour) meant anyone could type a
|
||||
* stranger's address and, because `users.email` is UNIQUE, permanently block
|
||||
* that stranger from ever signing up with Google. It also turned the uniqueness
|
||||
* error into an oracle for "is this address registered?".
|
||||
*
|
||||
* Nothing here is authoritative: the address only moves onto `users` once the
|
||||
* token comes back.
|
||||
*/
|
||||
export const emailChangeRequests = pgTable('email_change_requests', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
email: text('email').notNull(),
|
||||
/** Random, single-use. Compared in full; never rendered back to the client. */
|
||||
token: text('token').notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const emailChangeRequestsRelations = relations(emailChangeRequests, ({ one }) => ({
|
||||
user: one(users, { fields: [emailChangeRequests.userId], references: [users.id] }),
|
||||
}));
|
||||
+70
-2
@@ -39,7 +39,15 @@ function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The trade taxonomy, in display order — the picker and the landing-page trade
|
||||
* strip both render it top-to-bottom, so the order is the demand order: the
|
||||
* trades a city marketplace sees most first, the long tail after. Slugs are the
|
||||
* stable key (pros, jobs and tests reference them); names and icons are cosmetic.
|
||||
* Icons are lucide names, kebab-case.
|
||||
*/
|
||||
const CATEGORIES = [
|
||||
// Core trades — the eight that carry most of the volume.
|
||||
{ slug: 'plumber', name: 'Plumber', icon: 'shower-head' },
|
||||
{ slug: 'electrician', name: 'Electrician', icon: 'zap' },
|
||||
{ slug: 'handyman', name: 'Handyman', icon: 'wrench' },
|
||||
@@ -48,6 +56,60 @@ const CATEGORIES = [
|
||||
{ slug: 'locksmith', name: 'Locksmith', icon: 'key-round' },
|
||||
{ slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' },
|
||||
{ slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' },
|
||||
|
||||
// Home upkeep and renovation.
|
||||
{ slug: 'cleaner', name: 'House Cleaning', icon: 'sparkles' },
|
||||
{ slug: 'gardener', name: 'Gardening & Landscaping', icon: 'sprout' },
|
||||
{ slug: 'mover', name: 'Removals & Moving', icon: 'truck' },
|
||||
{ slug: 'builder', name: 'Builder & Renovation', icon: 'brick-wall' },
|
||||
{ slug: 'tiler', name: 'Tiling', icon: 'grid-2x2' },
|
||||
{ slug: 'plasterer', name: 'Plastering & Drywall', icon: 'layers' },
|
||||
{ slug: 'roofer', name: 'Roofing', icon: 'house' },
|
||||
{ slug: 'flooring', name: 'Flooring & Parquet', icon: 'grid-3x3' },
|
||||
{ slug: 'window-fitter', name: 'Windows & Glazing', icon: 'app-window' },
|
||||
{ slug: 'blinds-curtains', name: 'Blinds & Curtains', icon: 'blinds' },
|
||||
{ slug: 'kitchen-fitter', name: 'Kitchen Fitting', icon: 'cooking-pot' },
|
||||
{ slug: 'bathroom-fitter', name: 'Bathroom Fitting', icon: 'bath' },
|
||||
{ slug: 'gas-engineer', name: 'Gas & Boilers', icon: 'flame' },
|
||||
{ slug: 'solar', name: 'Solar & Batteries', icon: 'sun' },
|
||||
{ slug: 'drain-unblocking', name: 'Drains & Unblocking', icon: 'droplets' },
|
||||
{ slug: 'pest-control', name: 'Pest Control', icon: 'bug' },
|
||||
{ slug: 'waste-removal', name: 'Waste & Junk Removal', icon: 'trash-2' },
|
||||
{ slug: 'window-cleaner', name: 'Window Cleaning', icon: 'spray-can' },
|
||||
{ slug: 'pool-maintenance', name: 'Pool Maintenance', icon: 'waves' },
|
||||
{ slug: 'upholstery', name: 'Upholstery & Furniture Repair', icon: 'sofa' },
|
||||
{ slug: 'alarms-cctv', name: 'Alarms & CCTV', icon: 'cctv' },
|
||||
|
||||
// Devices and vehicles.
|
||||
{ slug: 'it-support', name: 'Computer & IT Support', icon: 'laptop' },
|
||||
{ slug: 'phone-repair', name: 'Phone & Tablet Repair', icon: 'smartphone' },
|
||||
{ slug: 'car-mechanic', name: 'Car Mechanic', icon: 'car' },
|
||||
{ slug: 'car-detailing', name: 'Car Wash & Detailing', icon: 'car-front' },
|
||||
|
||||
// People care.
|
||||
{ slug: 'babysitter', name: 'Childcare & Nannies', icon: 'baby' },
|
||||
{ slug: 'elderly-care', name: 'Elderly Care', icon: 'heart-handshake' },
|
||||
{ slug: 'pet-care', name: 'Pet Care & Dog Walking', icon: 'dog' },
|
||||
{ slug: 'massage', name: 'Massage & Physio', icon: 'hand-heart' },
|
||||
{ slug: 'hairdresser', name: 'Hairdresser & Barber', icon: 'scissors' },
|
||||
{ slug: 'beautician', name: 'Beauty & Nails', icon: 'gem' },
|
||||
{ slug: 'personal-trainer', name: 'Personal Trainer', icon: 'dumbbell' },
|
||||
|
||||
// Lessons.
|
||||
{ slug: 'tutor', name: 'Private Tutor', icon: 'graduation-cap' },
|
||||
{ slug: 'music-teacher', name: 'Music Lessons', icon: 'music' },
|
||||
|
||||
// Events and creative.
|
||||
{ slug: 'photographer', name: 'Photographer', icon: 'camera' },
|
||||
{ slug: 'videographer', name: 'Video & Drone', icon: 'video' },
|
||||
{ slug: 'dj', name: 'DJ & Live Music', icon: 'disc-3' },
|
||||
{ slug: 'catering', name: 'Catering & Private Chefs', icon: 'chef-hat' },
|
||||
{ slug: 'event-planner', name: 'Events & Parties', icon: 'party-popper' },
|
||||
|
||||
// Professional services.
|
||||
{ slug: 'accountant', name: 'Accounting & Tax', icon: 'calculator' },
|
||||
{ slug: 'lawyer', name: 'Legal Services', icon: 'scale' },
|
||||
{ slug: 'architect', name: 'Architect & Surveyor', icon: 'drafting-compass' },
|
||||
];
|
||||
|
||||
interface SeedPro {
|
||||
@@ -189,11 +251,17 @@ async function main() {
|
||||
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
|
||||
|
||||
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
|
||||
// The first photo is the deck card, so it has to be a FACE. picsum returns
|
||||
// landscape stock scenery — a locksmith on a railway track — which makes the
|
||||
// deck unreadable as a people-picker no matter what the layout does.
|
||||
// pravatar serves ~70 portraits; i is 0-based and img is 1-based.
|
||||
const portrait = (i % 70) + 1;
|
||||
await db.insert(schema.proMedia).values([
|
||||
{ proId: user.id, url: `https://picsum.photos/seed/${slug}-1/800/1000`, position: 0 },
|
||||
{ proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 },
|
||||
{
|
||||
// The second slot is genuinely for work: scenery is fine here.
|
||||
proId: user.id,
|
||||
url: `https://picsum.photos/seed/${slug}-2/800/1000`,
|
||||
url: `https://picsum.photos/seed/${slug}-work/800/1000`,
|
||||
kind: 'work_sample' as const,
|
||||
position: 1,
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* three pros specifically to prove each exclusion reason fires.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
@@ -61,6 +62,48 @@ describe('getShowcaseDeck', () => {
|
||||
expect(three).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('honours the searcher own range, not just the pro one', async () => {
|
||||
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel
|
||||
// here happily, but someone who said "within 3 km" did not ask for her.
|
||||
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
|
||||
const nearNames = near.map((c) => c.name);
|
||||
|
||||
expect(nearNames).not.toContain('Marta Vidal');
|
||||
expect(nearNames).toContain('Marc Oliveras'); // 800 m away
|
||||
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('narrows to a single trade when given a category', async () => {
|
||||
const [plumber] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
if (!plumber) throw new Error('plumber category missing from seed');
|
||||
|
||||
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
|
||||
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
// Every card carries its trade names, so the filter is checkable per card.
|
||||
expect(cards.every((c) => c.categories.includes('Plumber'))).toBe(true);
|
||||
// And it must be a strict subset — otherwise the filter did nothing.
|
||||
expect(cards.length).toBeLessThan(names.length);
|
||||
});
|
||||
|
||||
it('still excludes the ineligible when a category is given', async () => {
|
||||
const [plumber] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
if (!plumber) throw new Error('plumber category missing from seed');
|
||||
|
||||
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
|
||||
const filtered = cards.map((c) => c.name);
|
||||
|
||||
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this
|
||||
// proves the category filter did not replace the eligibility rules.
|
||||
expect(filtered).not.toContain('Pau Ribas');
|
||||
expect(filtered).not.toContain('Unverified Ulla');
|
||||
expect(filtered).not.toContain('Away Arnau');
|
||||
});
|
||||
|
||||
it('never returns a card with a distance beyond that pro’s own radius', async () => {
|
||||
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
|
||||
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
|
||||
|
||||
Reference in New Issue
Block a user