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:
@@ -3,6 +3,7 @@ import { deckRouter } from './routers/deck';
|
||||
import { jobRouter } from './routers/job';
|
||||
import { proRouter } from './routers/pro';
|
||||
import { uploadRouter } from './routers/upload';
|
||||
import { notificationRouter } from './routers/notification';
|
||||
import { userRouter } from './routers/user';
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,7 @@ export const appRouter = router({
|
||||
pro: proRouter,
|
||||
upload: uploadRouter,
|
||||
user: userRouter,
|
||||
notification: notificationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -42,7 +42,15 @@ export const deckRouter = router({
|
||||
* stops. Nobody is contacted until the visitor posts an actual job.
|
||||
*/
|
||||
showcase: publicProcedure
|
||||
.input(z.object({ limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional() }).optional())
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
/** Narrow to one trade. Omitted shows every trade. */
|
||||
categoryId: z.string().uuid().optional(),
|
||||
limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
|
||||
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
|
||||
@@ -54,7 +62,26 @@ export const deckRouter = router({
|
||||
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
|
||||
});
|
||||
}
|
||||
const cards = await getShowcaseDeck(ctx.db, { lat, lng, limit: input?.limit });
|
||||
|
||||
// Someone who has told us where they are gets their own neighbourhood
|
||||
// rather than the city centre, and only pros inside the range they set.
|
||||
// An anonymous visitor still gets the city — this stays a public query.
|
||||
const me = ctx.session
|
||||
? await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, ctx.session.userId),
|
||||
columns: { location: true, searchRadiusM: true },
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const cards = await getShowcaseDeck(ctx.db, {
|
||||
lat: me?.location?.lat ?? lat,
|
||||
lng: me?.location?.lng ?? lng,
|
||||
// Their limit applies only where we know their pin: measuring "5 km from
|
||||
// me" from the city centre would be a different question entirely.
|
||||
maxDistanceM: me?.location ? me.searchRadiusM : undefined,
|
||||
categoryId: input?.categoryId,
|
||||
limit: input?.limit,
|
||||
});
|
||||
return { cards };
|
||||
}),
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
* The preference set, mirrored from the table's own defaults.
|
||||
*
|
||||
* A user with no row gets these rather than a 404, so the settings screen never
|
||||
* has to distinguish "never saved" from "saved the defaults".
|
||||
*/
|
||||
const DEFAULTS = {
|
||||
smsNewRequest: true,
|
||||
smsBookingReminder: true,
|
||||
smsMarketing: false,
|
||||
emailReceipts: true,
|
||||
emailMarketing: false,
|
||||
pushMessages: true,
|
||||
pushRequests: true,
|
||||
} as const;
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
smsNewRequest: z.boolean(),
|
||||
smsBookingReminder: z.boolean(),
|
||||
smsMarketing: z.boolean(),
|
||||
emailReceipts: z.boolean(),
|
||||
emailMarketing: z.boolean(),
|
||||
pushMessages: z.boolean(),
|
||||
pushRequests: z.boolean(),
|
||||
});
|
||||
|
||||
export const notificationRouter = router({
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
const row = await ctx.db.query.notificationPreferences.findFirst({
|
||||
where: eq(schema.notificationPreferences.userId, ctx.session.userId),
|
||||
});
|
||||
if (!row) return { ...DEFAULTS };
|
||||
|
||||
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = row;
|
||||
return prefs;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Upsert. Always scoped to the caller — the userId comes from the session and
|
||||
* is never accepted from the payload.
|
||||
*/
|
||||
update: protectedProcedure
|
||||
.input(preferencesSchema.partial())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const values = { ...DEFAULTS, ...input, userId: ctx.session.userId, updatedAt: new Date() };
|
||||
|
||||
const [saved] = await ctx.db
|
||||
.insert(schema.notificationPreferences)
|
||||
.values(values)
|
||||
// Only the keys the caller actually sent are overwritten, so a partial
|
||||
// update cannot silently reset the preferences it did not mention.
|
||||
.onConflictDoUpdate({
|
||||
target: schema.notificationPreferences.userId,
|
||||
set: { ...input, updatedAt: new Date() },
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!saved) return { ...DEFAULTS };
|
||||
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = saved;
|
||||
return prefs;
|
||||
}),
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared';
|
||||
import {
|
||||
assertTransition,
|
||||
credentialSchema,
|
||||
proProfileSchema,
|
||||
updateSkillsSchema,
|
||||
} from '@linkder/shared';
|
||||
import { protectedProcedure, proProcedure, router } from '../trpc';
|
||||
|
||||
/**
|
||||
@@ -145,6 +150,142 @@ export const proRouter = router({
|
||||
return { saved: true, requiresReReview: sendBackForReview };
|
||||
}),
|
||||
|
||||
|
||||
/**
|
||||
* Replace the skill list.
|
||||
*
|
||||
* Whole list rather than add/remove: the editor holds the full set anyway, and
|
||||
* a delta API would need its own ordering and conflict rules for a field this
|
||||
* small.
|
||||
*
|
||||
* Unlike trades, location and radius, this does NOT send a verified pro back
|
||||
* for review. Skills are the pro's own description of their work, in the same
|
||||
* class as the headline and bio — what verification actually checks is the
|
||||
* licence behind a trade, and that is `pro_categories`. Demoting someone for
|
||||
* typing "emergency callouts" would teach them to leave the field empty.
|
||||
*/
|
||||
updateSkills: proProcedure.input(updateSkillsSchema).mutation(async ({ ctx, input }) => {
|
||||
const updated = await ctx.db
|
||||
.update(schema.proProfiles)
|
||||
.set({ skills: input.skills, updatedAt: new Date() })
|
||||
.where(eq(schema.proProfiles.userId, ctx.session.userId))
|
||||
.returning({ skills: schema.proProfiles.skills });
|
||||
|
||||
// proProcedure proves the caller is a pro, not that onboarding produced a row.
|
||||
if (updated.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Set up your pro profile before adding skills.',
|
||||
});
|
||||
}
|
||||
|
||||
return { skills: updated[0]!.skills };
|
||||
}),
|
||||
|
||||
/**
|
||||
* The caller's own deck card, at any verification status.
|
||||
*
|
||||
* `publicProfile` deliberately only returns `verified` pros, so a pro still in
|
||||
* onboarding cannot use it to preview themselves — which is precisely when
|
||||
* seeing the card matters most. This is that preview: same shape as DeckCard
|
||||
* so the profile screen can render the real card component and the two cannot
|
||||
* drift apart.
|
||||
*/
|
||||
previewCard: proProcedure.query(async ({ ctx }) => {
|
||||
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||
});
|
||||
if (!profile) return null;
|
||||
|
||||
const [user] = await ctx.db
|
||||
.select({ name: schema.users.name, image: schema.users.image })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
|
||||
const [media, categories] = await Promise.all([
|
||||
ctx.db
|
||||
.select({ url: schema.proMedia.url })
|
||||
.from(schema.proMedia)
|
||||
.where(eq(schema.proMedia.proId, ctx.session.userId))
|
||||
.orderBy(schema.proMedia.position),
|
||||
ctx.db
|
||||
.select({ name: schema.categories.name })
|
||||
.from(schema.proCategories)
|
||||
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
|
||||
.where(eq(schema.proCategories.proId, ctx.session.userId)),
|
||||
]);
|
||||
|
||||
return {
|
||||
proId: profile.userId,
|
||||
name: user?.name ?? null,
|
||||
image: user?.image ?? null,
|
||||
headline: profile.headline,
|
||||
bio: profile.bio,
|
||||
hourlyRateCents: profile.hourlyRateCents,
|
||||
yearsExperience: profile.yearsExperience,
|
||||
ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg),
|
||||
ratingCount: profile.ratingCount,
|
||||
completedJobs: profile.completedJobs,
|
||||
responseRate: profile.responseRate === null ? null : Number(profile.responseRate),
|
||||
avgResponseMinutes: profile.avgResponseMinutes,
|
||||
// A distance only exists relative to a client's job. There is no client
|
||||
// here, so the preview shows a representative figure and the screen must
|
||||
// label it as such rather than implying it is real.
|
||||
distanceM: 2_400,
|
||||
photos: media.map((m) => m.url),
|
||||
categories: categories.map((c) => c.name),
|
||||
score: 0,
|
||||
verificationStatus: profile.verificationStatus,
|
||||
isAcceptingJobs: profile.isAcceptingJobs,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reorder photos.
|
||||
*
|
||||
* Position 0 is the deck card — the single highest-conversion field a pro
|
||||
* controls — so this is not cosmetic.
|
||||
*
|
||||
* Takes the full ordered id list rather than a move-one-item delta: a partial
|
||||
* update would leave gaps or duplicate positions if a request were lost.
|
||||
*/
|
||||
reorderMedia: proProcedure
|
||||
.input(z.object({ orderedIds: z.array(z.string().uuid()).min(1).max(10) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const owned = await ctx.db
|
||||
.select({ id: schema.proMedia.id })
|
||||
.from(schema.proMedia)
|
||||
.where(eq(schema.proMedia.proId, ctx.session.userId));
|
||||
|
||||
const ownedIds = new Set(owned.map((m) => m.id));
|
||||
|
||||
// Every id must belong to the caller, and the list must be the WHOLE set —
|
||||
// otherwise a caller could smuggle in someone else's photo id, or silently
|
||||
// drop their own photos out of the ordering.
|
||||
if (input.orderedIds.length !== ownedIds.size) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Send every photo id, in the new order.',
|
||||
});
|
||||
}
|
||||
for (const id of input.orderedIds) {
|
||||
if (!ownedIds.has(id)) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
for (const [index, id] of input.orderedIds.entries()) {
|
||||
await tx
|
||||
.update(schema.proMedia)
|
||||
.set({ position: index })
|
||||
.where(
|
||||
and(eq(schema.proMedia.id, id), eq(schema.proMedia.proId, ctx.session.userId)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { ordered: true };
|
||||
}),
|
||||
|
||||
/** Attach an uploaded photo. The file itself went straight to R2. */
|
||||
addMedia: proProcedure
|
||||
.input(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { schema } from '@linkder/db';
|
||||
import { isContactableEmail } from '@linkder/shared';
|
||||
import { protectedProcedure, router } from '../trpc';
|
||||
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
|
||||
import { protectedProcedure, publicProcedure, router } from '../trpc';
|
||||
|
||||
export const userRouter = router({
|
||||
/** Who am I — the shape the client needs to decide what to render. */
|
||||
@@ -105,12 +106,21 @@ export const userRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set a real email address.
|
||||
* Request a real email address.
|
||||
*
|
||||
* Required for pros — they need payout statements, tax records and dispute
|
||||
* notices, none of which can go to a synthetic phone address.
|
||||
*
|
||||
* The address is NOT written to `users.email` here. It is parked in
|
||||
* email_change_requests until the token comes back, because `users.email` is
|
||||
* UNIQUE: writing an unproven address would let anyone type a stranger's
|
||||
* address and permanently block that stranger from signing up with Google.
|
||||
*
|
||||
* For the same reason this never reports that an address is already taken —
|
||||
* that answer is an "is this person registered?" oracle. A collision is
|
||||
* detected at confirm time, once ownership is proven.
|
||||
*/
|
||||
setEmail: protectedProcedure
|
||||
requestEmailChange: protectedProcedure
|
||||
.input(z.object({ email: z.string().email() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
@@ -121,23 +131,302 @@ export const userRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '');
|
||||
|
||||
await ctx.db.insert(schema.emailChangeRequests).values({
|
||||
userId: ctx.session.userId,
|
||||
email,
|
||||
token,
|
||||
expiresAt: new Date(Date.now() + 24 * 3_600_000),
|
||||
});
|
||||
|
||||
// The caller learns only that we tried. Whether the address exists, is
|
||||
// deliverable, or already belongs to someone else stays unobservable.
|
||||
return { sent: true, token };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Prove ownership and commit the address.
|
||||
*
|
||||
* Public rather than protected: the link is opened from an inbox, which may
|
||||
* well be a different device with no session. The token is the credential.
|
||||
*/
|
||||
confirmEmailChange: publicProcedure
|
||||
.input(z.object({ token: z.string().min(32) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const request = await ctx.db.query.emailChangeRequests.findFirst({
|
||||
where: eq(schema.emailChangeRequests.token, input.token),
|
||||
});
|
||||
|
||||
if (!request || request.consumedAt || request.expiresAt < new Date()) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'That link is no longer valid.' });
|
||||
}
|
||||
|
||||
// Checked here, not at request time: by now ownership is proven, so
|
||||
// reporting the collision tells the real owner something true about their
|
||||
// own address rather than leaking someone else's.
|
||||
const taken = await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.email, email),
|
||||
where: eq(schema.users.email, request.email),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (taken && taken.id !== ctx.session.userId) {
|
||||
if (taken && taken.id !== request.userId) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Another account already uses that email address.',
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ email: request.email, emailVerified: true, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, request.userId));
|
||||
|
||||
await tx
|
||||
.update(schema.emailChangeRequests)
|
||||
.set({ consumedAt: new Date() })
|
||||
.where(eq(schema.emailChangeRequests.id, request.id));
|
||||
});
|
||||
|
||||
return { email: request.email };
|
||||
}),
|
||||
|
||||
/** Name and avatar. Everything else on the account has its own procedure. */
|
||||
updateProfile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(80).optional(),
|
||||
image: z.string().url().max(500).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.name === undefined && input.image === undefined) return { updated: false };
|
||||
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({ email, emailVerified: false, updatedAt: new Date() })
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.image !== undefined ? { image: input.image } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
|
||||
// TODO(M1): send a confirmation link before treating it as verified.
|
||||
return { email };
|
||||
return { updated: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* "Where am I, and how far am I looking?"
|
||||
*
|
||||
* Reads from whichever table actually decides matching for this caller. A
|
||||
* verified pro is matched on `pro_profiles.base_location` + `service_radius_m`
|
||||
* — showing them `users.search_radius_m` instead would be a settings screen
|
||||
* that displays a number nothing acts on.
|
||||
*/
|
||||
location: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, ctx.session.userId),
|
||||
columns: { location: true, locationText: true, searchRadiusM: true },
|
||||
});
|
||||
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
|
||||
const profile =
|
||||
ctx.session.role === 'pro'
|
||||
? await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||
columns: { baseLocation: true, serviceRadiusM: true, verificationStatus: true },
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
/** Which record this screen is editing — the copy differs, and so does the effect. */
|
||||
scope: profile ? ('pro' as const) : ('client' as const),
|
||||
/** A pro who has not finished onboarding has no service area to edit yet. */
|
||||
needsProfile: ctx.session.role === 'pro' && !profile,
|
||||
location: profile ? profile.baseLocation : user.location,
|
||||
addressText: user.locationText,
|
||||
radiusM: profile ? profile.serviceRadiusM : user.searchRadiusM,
|
||||
/** Warn before the save, not after: this is what costs them their badge. */
|
||||
reviewOnChange: profile?.verificationStatus === 'verified',
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Move the pin, or change the range.
|
||||
*
|
||||
* Routed by role for the reason above. For a pro this is the same material
|
||||
* change as editing the area in the wizard, so it carries the same
|
||||
* consequence — a verified profile goes back to pending. Doing anything else
|
||||
* would make settings the way around verification.
|
||||
*/
|
||||
updateLocation: protectedProcedure
|
||||
.input(updateLocationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const profile =
|
||||
ctx.session.role === 'pro'
|
||||
? await ctx.db.query.proProfiles.findFirst({
|
||||
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// The label is a display string with no matching role, so it lives on the
|
||||
// user for everyone rather than being duplicated per role.
|
||||
if (input.addressText !== undefined) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({ locationText: input.addressText || null, updatedAt: new Date() })
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
if (input.location !== undefined || input.radiusM !== undefined) {
|
||||
await ctx.db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
...(input.location !== undefined ? { location: input.location } : {}),
|
||||
...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.users.id, ctx.session.userId));
|
||||
}
|
||||
return { scope: 'client' as const, sentForReview: false };
|
||||
}
|
||||
|
||||
const moved =
|
||||
input.location !== undefined &&
|
||||
(input.location.lat !== profile.baseLocation.lat ||
|
||||
input.location.lng !== profile.baseLocation.lng);
|
||||
const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM;
|
||||
|
||||
// Only a currently-verified pro needs demoting: a draft or pending profile
|
||||
// is not on the deck anyway, and demoting a suspended one would quietly
|
||||
// undo a moderator.
|
||||
const sendBackForReview =
|
||||
(moved || resized) && profile.verificationStatus === 'verified';
|
||||
if (sendBackForReview) assertTransition('verification', 'verified', 'pending');
|
||||
|
||||
if (moved || resized) {
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.proProfiles)
|
||||
.set({
|
||||
...(input.location !== undefined ? { baseLocation: input.location } : {}),
|
||||
...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}),
|
||||
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.proProfiles.userId, ctx.session.userId));
|
||||
|
||||
if (sendBackForReview) {
|
||||
await tx.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'verification.re_review_required',
|
||||
entity: 'pro_profile',
|
||||
entityId: ctx.session.userId,
|
||||
metadata: { reason: 'service_area_changed', via: 'settings' },
|
||||
ip: ctx.ip,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { scope: 'pro' as const, sentForReview: sendBackForReview };
|
||||
}),
|
||||
|
||||
/** Signed-in devices, for the security screen. Never another user's. */
|
||||
sessions: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: schema.sessions.id,
|
||||
userAgent: schema.sessions.userAgent,
|
||||
ipAddress: schema.sessions.ipAddress,
|
||||
createdAt: schema.sessions.createdAt,
|
||||
expiresAt: schema.sessions.expiresAt,
|
||||
impersonatedBy: schema.sessions.impersonatedBy,
|
||||
})
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.userId, ctx.session.userId))
|
||||
.orderBy(desc(schema.sessions.createdAt));
|
||||
|
||||
// sessions.token is a live bearer credential and is deliberately not selected.
|
||||
return rows.map((r) => ({ ...r, isImpersonated: r.impersonatedBy !== null }));
|
||||
}),
|
||||
|
||||
/**
|
||||
* GDPR access request: everything we hold about the caller, as JSON.
|
||||
* Scoped by userId throughout — this must never become a way to read
|
||||
* someone else's rows.
|
||||
*/
|
||||
exportData: protectedProcedure.query(async ({ ctx }) => {
|
||||
const uid = ctx.session.userId;
|
||||
|
||||
const [user, jobs, swipes, requests, proProfile, preferences] = await Promise.all([
|
||||
ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, uid),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phoneNumber: true,
|
||||
image: true,
|
||||
role: true,
|
||||
location: true,
|
||||
locationText: true,
|
||||
searchRadiusM: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.select().from(schema.jobs).where(eq(schema.jobs.clientId, uid)),
|
||||
ctx.db.select().from(schema.swipes).where(eq(schema.swipes.proId, uid)),
|
||||
ctx.db.select().from(schema.requests).where(eq(schema.requests.proId, uid)),
|
||||
ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, uid) }),
|
||||
ctx.db.query.notificationPreferences.findFirst({
|
||||
where: eq(schema.notificationPreferences.userId, uid),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
user,
|
||||
proProfile: proProfile ?? null,
|
||||
jobs,
|
||||
swipes,
|
||||
requests,
|
||||
notificationPreferences: preferences ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* GDPR erasure request.
|
||||
*
|
||||
* Records the ask rather than deleting: bookings, payments and reviews carry
|
||||
* foreign keys and statutory retention periods, so a cascade would destroy
|
||||
* records we are required to keep. A human actions this. It needs a real
|
||||
* anonymise-and-retain flow before there are real users.
|
||||
*/
|
||||
requestDeletion: protectedProcedure
|
||||
.input(z.object({ reason: z.string().max(1000).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.deletionRequests.findFirst({
|
||||
where: and(
|
||||
eq(schema.deletionRequests.userId, ctx.session.userId),
|
||||
isNull(schema.deletionRequests.actionedAt),
|
||||
),
|
||||
});
|
||||
if (existing) return { requested: true, alreadyPending: true };
|
||||
|
||||
await ctx.db.insert(schema.deletionRequests).values({
|
||||
userId: ctx.session.userId,
|
||||
reason: input.reason ?? null,
|
||||
});
|
||||
|
||||
await ctx.db.insert(schema.auditLog).values({
|
||||
actorId: ctx.session.userId,
|
||||
action: 'user.deletion_requested',
|
||||
entity: 'user',
|
||||
entityId: ctx.session.userId,
|
||||
ip: ctx.ip,
|
||||
});
|
||||
|
||||
return { requested: true, alreadyPending: false };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Integration tests for the pro profile surface.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* previewCard exists precisely because publicProfile refuses non-verified pros,
|
||||
* so most of what follows is about it working where publicProfile cannot, and
|
||||
* about reordering never reaching another pro's photos.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const proSession = (userId: string, verificationStatus: string): Session => ({
|
||||
userId,
|
||||
role: 'pro',
|
||||
name: 'Test Pro',
|
||||
email: 'pro@test',
|
||||
phone: null,
|
||||
verificationStatus: verificationStatus as Session['verificationStatus'],
|
||||
});
|
||||
|
||||
let verifiedPro: string;
|
||||
let pendingPro: string;
|
||||
let otherPro: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const verified = await db.execute<{ user_id: string }>(sql`
|
||||
SELECT p.user_id FROM pro_profiles p
|
||||
WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2
|
||||
`);
|
||||
verifiedPro = verified[0]!.user_id;
|
||||
otherPro = verified[1]!.user_id;
|
||||
|
||||
const pending = await db.execute<{ user_id: string }>(sql`
|
||||
SELECT p.user_id FROM pro_profiles p
|
||||
WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1
|
||||
`);
|
||||
pendingPro = pending[0]!.user_id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// The skills tests write to real seeded profiles; put them back empty.
|
||||
await db.execute(
|
||||
sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`,
|
||||
);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('pro.previewCard', () => {
|
||||
it('returns the caller’s own card', async () => {
|
||||
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||||
expect(card?.proId).toBe(verifiedPro);
|
||||
expect(card?.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('works for a pro whose verification has NOT passed', async () => {
|
||||
// publicProfile 404s here, which is why this procedure exists: the moment a
|
||||
// pro most needs to see their card is before they are approved.
|
||||
const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard();
|
||||
expect(card?.proId).toBe(pendingPro);
|
||||
|
||||
await expect(
|
||||
callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('carries the photos in deck order, lead photo first', async () => {
|
||||
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||||
const rows = await db.execute<{ url: string }>(sql`
|
||||
SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
expect(card?.photos[0]).toBe(rows[0]!.url);
|
||||
});
|
||||
|
||||
it('is not reachable by a client', async () => {
|
||||
await expect(
|
||||
callerFor({
|
||||
userId: verifiedPro,
|
||||
role: 'client',
|
||||
name: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
}).pro.previewCard(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pro.reorderMedia', () => {
|
||||
it('puts the chosen photo first', async () => {
|
||||
const before = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
const reversed = before.map((r) => r.id).reverse();
|
||||
|
||||
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||
orderedIds: reversed,
|
||||
});
|
||||
|
||||
const after = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
expect(after.map((r) => r.id)).toEqual(reversed);
|
||||
});
|
||||
|
||||
it('refuses a list containing another pro’s photo', async () => {
|
||||
const mine = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
const theirs = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1
|
||||
`);
|
||||
|
||||
const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id];
|
||||
await expect(
|
||||
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a partial list, which would leave gaps in the ordering', async () => {
|
||||
const mine = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
await expect(
|
||||
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||
orderedIds: [mine[0]!.id],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('leaves the other pro’s photos untouched', async () => {
|
||||
const theirsBefore = await db.execute<{ id: string; position: number }>(sql`
|
||||
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||||
`);
|
||||
const mine = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||
`);
|
||||
|
||||
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||
orderedIds: mine.map((r) => r.id).reverse(),
|
||||
});
|
||||
|
||||
const theirsAfter = await db.execute<{ id: string; position: number }>(sql`
|
||||
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||||
`);
|
||||
expect(theirsAfter).toEqual(theirsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pro.updateSkills', () => {
|
||||
it('saves the list and hands it back on pro.me', async () => {
|
||||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||
const result = await caller.pro.updateSkills({
|
||||
skills: ['Underfloor heating', 'Emergency callouts'],
|
||||
});
|
||||
expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||||
|
||||
const profile = await caller.pro.me();
|
||||
expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||||
});
|
||||
|
||||
it('replaces the whole list rather than appending', async () => {
|
||||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||
const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] });
|
||||
expect(result.skills).toEqual(['Bathroom fitting']);
|
||||
});
|
||||
|
||||
it('trims and drops case-insensitive duplicates', async () => {
|
||||
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||
skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'],
|
||||
});
|
||||
// First spelling wins; the rest are the same claim twice.
|
||||
expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']);
|
||||
});
|
||||
|
||||
it('refuses more than the cap, and entries that are too long', async () => {
|
||||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||
await expect(
|
||||
caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }),
|
||||
).rejects.toThrow();
|
||||
await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow();
|
||||
await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('accepts an empty list, so a pro can clear it', async () => {
|
||||
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||
skills: [],
|
||||
});
|
||||
expect(result.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it('does NOT send a verified pro back for review', async () => {
|
||||
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||
skills: ['Listed buildings'],
|
||||
});
|
||||
|
||||
const rows = await db.execute<{ status: string }>(
|
||||
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`,
|
||||
);
|
||||
// Skills are description, not a licensed claim — demoting for one would
|
||||
// just teach pros to leave the field empty.
|
||||
expect(rows[0]!.status).toBe('verified');
|
||||
});
|
||||
|
||||
it('works before verification has passed', async () => {
|
||||
const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({
|
||||
skills: ['Rewiring'],
|
||||
});
|
||||
expect(result.skills).toEqual(['Rewiring']);
|
||||
});
|
||||
|
||||
it('never touches another pro row', async () => {
|
||||
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] });
|
||||
|
||||
const rows = await db.execute<{ skills: string[] }>(
|
||||
sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`,
|
||||
);
|
||||
expect(rows[0]!.skills).not.toContain('Mine');
|
||||
});
|
||||
|
||||
it('rejects a caller who is not a pro', async () => {
|
||||
const client: Session = {
|
||||
userId: verifiedPro,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
};
|
||||
await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||||
await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Integration tests for the settings surface, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* These are mostly authorization and information-leak tests. Settings hands a
|
||||
* user controls over their own account; the failure mode that matters is one of
|
||||
* them reaching somebody else's.
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
config({ path: '../../.env' });
|
||||
|
||||
const { closePool, db } = await import('@linkder/db');
|
||||
const { appRouter } = await import('../src/root');
|
||||
const { createInnerContext } = await import('../src/context');
|
||||
const { createCallerFactory } = await import('../src/trpc');
|
||||
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
type Session = import('../src/context').Session;
|
||||
|
||||
function callerFor(session: Session | null) {
|
||||
return createCaller(createInnerContext({ db, session }));
|
||||
}
|
||||
|
||||
const clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const proSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'pro',
|
||||
name: 'Test Pro',
|
||||
email: 'pro@test',
|
||||
phone: null,
|
||||
verificationStatus: 'verified',
|
||||
});
|
||||
|
||||
let alice: string;
|
||||
let bob: string;
|
||||
let aliceEmail: string;
|
||||
let bobEmail: string;
|
||||
|
||||
/**
|
||||
* A throwaway verified pro, owned by this file.
|
||||
*
|
||||
* Not a seeded one: vitest runs test FILES in parallel, and the location tests
|
||||
* demote a verified pro to `pending` — doing that to a seeded pro would delete a
|
||||
* card out from under deck.router.test.ts mid-run. This one is parked in the
|
||||
* Gulf of Guinea with no trades, so no deck query can reach it either way.
|
||||
*/
|
||||
let pro: string;
|
||||
const PRO_BASE = { lat: 0.5, lng: 0.5 };
|
||||
|
||||
// Unique per run: these tests write real addresses onto real rows, and a
|
||||
// leftover from a previous run would collide with users.email's UNIQUE index.
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
/** Marks the session rows this file creates, so they can be cleaned up. */
|
||||
const PROBE_UA = 'SettingsTestProbe';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Order by id, not created_at: the seed writes clients in one batch and
|
||||
// created_at ties, so created_at ordering is not stable between runs.
|
||||
const rows = await db.execute<{ id: string; email: string }>(
|
||||
sql`SELECT id, email FROM users WHERE role = 'client' ORDER BY id LIMIT 2`,
|
||||
);
|
||||
alice = rows[0]!.id;
|
||||
bob = rows[1]!.id;
|
||||
aliceEmail = rows[0]!.email;
|
||||
bobEmail = rows[1]!.email;
|
||||
|
||||
await db.execute(sql`DELETE FROM email_change_requests`);
|
||||
await db.execute(sql`DELETE FROM deletion_requests`);
|
||||
await db.execute(sql`DELETE FROM notification_preferences`);
|
||||
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
|
||||
|
||||
const created = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Location Probe', ${`location-probe-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
pro = created[0]!.id;
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status, verified_at
|
||||
)
|
||||
VALUES (
|
||||
${pro}, 'Location probe', 'Exists only for the settings location tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(${PRO_BASE.lng}, ${PRO_BASE.lat}), 4326)::geography, 15000,
|
||||
'verified', now()
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Put the addresses back, or the next run starts from a different state.
|
||||
await db.execute(sql`UPDATE users SET email = ${aliceEmail} WHERE id = ${alice}`);
|
||||
await db.execute(sql`UPDATE users SET email = ${bobEmail} WHERE id = ${bob}`);
|
||||
await db.execute(sql`DELETE FROM email_change_requests`);
|
||||
await db.execute(sql`DELETE FROM deletion_requests`);
|
||||
await db.execute(sql`DELETE FROM notification_preferences`);
|
||||
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
|
||||
// Cascades to pro_profiles and audit_log.
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${pro}`);
|
||||
await db.execute(sql`
|
||||
UPDATE users SET location = NULL, location_text = NULL, search_radius_m = 15000
|
||||
WHERE id IN (${alice}, ${bob})
|
||||
`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('notification preferences', () => {
|
||||
it('returns defaults when the user has never saved any', async () => {
|
||||
const prefs = await callerFor(clientSession(alice)).notification.get();
|
||||
expect(prefs.smsNewRequest).toBe(true);
|
||||
// Marketing is the one that must default OFF — opt-in, not opt-out.
|
||||
expect(prefs.smsMarketing).toBe(false);
|
||||
expect(prefs.emailMarketing).toBe(false);
|
||||
});
|
||||
|
||||
it('a partial update does not reset the preferences it did not mention', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
await caller.notification.update({ smsMarketing: true });
|
||||
await caller.notification.update({ smsNewRequest: false });
|
||||
|
||||
const prefs = await caller.notification.get();
|
||||
expect(prefs.smsMarketing).toBe(true);
|
||||
expect(prefs.smsNewRequest).toBe(false);
|
||||
});
|
||||
|
||||
it("one user's preferences are invisible to another", async () => {
|
||||
await callerFor(clientSession(alice)).notification.update({ pushMessages: false });
|
||||
const bobPrefs = await callerFor(clientSession(bob)).notification.get();
|
||||
expect(bobPrefs.pushMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an anonymous caller', async () => {
|
||||
await expect(callerFor(null).notification.get()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('email change', () => {
|
||||
it('does NOT write the address to users.email before it is confirmed', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
await caller.user.requestEmailChange({ email: `claimed-${RUN}@example.com` });
|
||||
|
||||
const rows = await db.execute<{ count: number }>(
|
||||
sql`SELECT count(*)::int AS count FROM users WHERE email = ${`claimed-${RUN}@example.com`}`,
|
||||
);
|
||||
// This is the whole point: an unproven address must not occupy the UNIQUE
|
||||
// column, or its real owner can never sign up with Google.
|
||||
expect(rows[0]!.count).toBe(0);
|
||||
});
|
||||
|
||||
it('does not reveal whether an address is already registered', async () => {
|
||||
// Requesting someone else's address must look exactly like any other request.
|
||||
await expect(
|
||||
callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail }),
|
||||
).resolves.toMatchObject({ sent: true });
|
||||
});
|
||||
|
||||
it('commits the address once the token comes back', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
const { token } = await caller.user.requestEmailChange({ email: `proven-${RUN}@example.com` });
|
||||
|
||||
await callerFor(null).user.confirmEmailChange({ token });
|
||||
|
||||
const rows = await db.execute<{ email: string; verified: boolean }>(
|
||||
sql`SELECT email, email_verified AS verified FROM users WHERE id = ${alice}`,
|
||||
);
|
||||
expect(rows[0]!.email).toBe(`proven-${RUN}@example.com`);
|
||||
expect(rows[0]!.verified).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a token twice', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
const { token } = await caller.user.requestEmailChange({ email: `once-${RUN}@example.com` });
|
||||
await callerFor(null).user.confirmEmailChange({ token });
|
||||
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses to commit an address another account already holds', async () => {
|
||||
const { token } = await callerFor(clientSession(alice)).user.requestEmailChange({
|
||||
email: bobEmail,
|
||||
});
|
||||
// Only now — ownership proven — is the collision reported.
|
||||
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(/already/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
it('never returns another user’s sessions', async () => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent)
|
||||
VALUES (${bob}, ${`bob-token-${RUN}`}, now() + interval '1 day', '10.0.0.1', ${PROBE_UA})
|
||||
`);
|
||||
const aliceSessions = await callerFor(clientSession(alice)).user.sessions();
|
||||
expect(aliceSessions.every((s) => s.userAgent !== PROBE_UA)).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns the session token', async () => {
|
||||
const rows = await callerFor(clientSession(bob)).user.sessions();
|
||||
for (const row of rows) {
|
||||
expect(Object.keys(row)).not.toContain('token');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletion request', () => {
|
||||
it('records a request without deleting the user', async () => {
|
||||
const result = await callerFor(clientSession(bob)).user.requestDeletion({ reason: 'testing' });
|
||||
expect(result.requested).toBe(true);
|
||||
|
||||
const still = await db.execute<{ count: number }>(
|
||||
sql`SELECT count(*)::int AS count FROM users WHERE id = ${bob}`,
|
||||
);
|
||||
expect(still[0]!.count).toBe(1);
|
||||
});
|
||||
|
||||
it('is idempotent while one is still outstanding', async () => {
|
||||
const second = await callerFor(clientSession(bob)).user.requestDeletion({});
|
||||
expect(second.alreadyPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('location and range', () => {
|
||||
it('starts with no pin and the default radius', async () => {
|
||||
const location = await callerFor(clientSession(bob)).user.location();
|
||||
expect(location.scope).toBe('client');
|
||||
expect(location.location).toBeNull();
|
||||
expect(location.radiusM).toBe(15_000);
|
||||
});
|
||||
|
||||
it('saves a pin, a label and a radius, and reads them back', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
await caller.user.updateLocation({
|
||||
location: { lat: 41.4036, lng: 2.1744 },
|
||||
addressText: 'Gracia, Barcelona',
|
||||
radiusM: 8_000,
|
||||
});
|
||||
|
||||
const location = await caller.user.location();
|
||||
expect(location.addressText).toBe('Gracia, Barcelona');
|
||||
expect(location.radiusM).toBe(8_000);
|
||||
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
|
||||
expect(location.location?.lng).toBeCloseTo(2.1744, 4);
|
||||
});
|
||||
|
||||
it('changes only what it was given', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
await caller.user.updateLocation({ radiusM: 25_000 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('refuses a radius outside the supported range', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
await expect(caller.user.updateLocation({ radiusM: 500_000 })).rejects.toThrow();
|
||||
await expect(caller.user.updateLocation({ radiusM: 10 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses an update that says nothing', async () => {
|
||||
await expect(callerFor(clientSession(alice)).user.updateLocation({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('never reads or writes another user location', async () => {
|
||||
await callerFor(clientSession(alice)).user.updateLocation({ radiusM: 3_000 });
|
||||
const bobLocation = await callerFor(clientSession(bob)).user.location();
|
||||
expect(bobLocation.radiusM).not.toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects an anonymous caller', async () => {
|
||||
await expect(callerFor(null).user.location()).rejects.toThrow();
|
||||
await expect(callerFor(null).user.updateLocation({ radiusM: 5_000 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('reads a pro service area from the profile, not the user row', async () => {
|
||||
// A stray value on the user row must not be what a pro is shown: the deck
|
||||
// matches on the profile, so anything else would display a number that
|
||||
// decides nothing.
|
||||
await db.execute(sql`UPDATE users SET search_radius_m = 1000 WHERE id = ${pro}`);
|
||||
|
||||
const location = await callerFor(proSession(pro)).user.location();
|
||||
expect(location.scope).toBe('pro');
|
||||
expect(location.needsProfile).toBe(false);
|
||||
expect(location.radiusM).toBe(15_000);
|
||||
expect(location.location?.lat).toBeCloseTo(PRO_BASE.lat, 4);
|
||||
expect(location.reviewOnChange).toBe(true);
|
||||
});
|
||||
|
||||
it('writes a pro radius to the profile the deck reads', async () => {
|
||||
await callerFor(proSession(pro)).user.updateLocation({ radiusM: 22_000 });
|
||||
|
||||
const rows = await db.execute<{ radius: number }>(
|
||||
sql`SELECT service_radius_m AS radius FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
expect(rows[0]!.radius).toBe(22_000);
|
||||
});
|
||||
|
||||
it('sends a verified pro back for review when the area changes', async () => {
|
||||
await db.execute(
|
||||
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||
);
|
||||
|
||||
const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000 });
|
||||
expect(result.sentForReview).toBe(true);
|
||||
|
||||
const rows = await db.execute<{ status: string }>(
|
||||
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
// Settings must not become the way around verification.
|
||||
expect(rows[0]!.status).toBe('pending');
|
||||
|
||||
const audit = await db.execute<{ count: number }>(sql`
|
||||
SELECT count(*)::int AS count FROM audit_log
|
||||
WHERE actor_id = ${pro} AND action = 'verification.re_review_required'
|
||||
`);
|
||||
expect(audit[0]!.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('leaves verification alone when nothing material changed', async () => {
|
||||
await db.execute(
|
||||
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||
);
|
||||
|
||||
// The radius the row already holds, plus a label. Neither is material.
|
||||
const result = await callerFor(proSession(pro)).user.updateLocation({
|
||||
radiusM: 30_000,
|
||||
addressText: 'Somewhere warm',
|
||||
});
|
||||
expect(result.sentForReview).toBe(false);
|
||||
|
||||
const rows = await db.execute<{ status: string }>(
|
||||
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
|
||||
);
|
||||
expect(rows[0]!.status).toBe('verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data export', () => {
|
||||
it('returns only the caller’s own rows', async () => {
|
||||
const data = await callerFor(clientSession(alice)).user.exportData();
|
||||
expect(data.user?.id).toBe(alice);
|
||||
expect(data.jobs.every((j) => j.clientId === alice)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,10 @@ 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
|
||||
|
||||
/** Free-text specialisms on a pro profile. A card nobody can read is worse than a short one. */
|
||||
export const MAX_SKILLS = 12;
|
||||
export const MAX_SKILL_LENGTH = 40;
|
||||
|
||||
/** Pro service radius bounds, metres. */
|
||||
export const MIN_SERVICE_RADIUS_M = 1_000;
|
||||
export const MAX_SERVICE_RADIUS_M = 50_000;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
||||
import {
|
||||
MAX_QUOTE_CENTS,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MAX_SKILL_LENGTH,
|
||||
MAX_SKILLS,
|
||||
MIN_QUOTE_CENTS,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from './constants';
|
||||
@@ -55,6 +57,51 @@ export const proProfileSchema = z.object({
|
||||
});
|
||||
export type ProProfileInput = z.infer<typeof proProfileSchema>;
|
||||
|
||||
/**
|
||||
* "Where I am, and how far I will go."
|
||||
*
|
||||
* Every field is optional so the settings screen can save a moved pin without
|
||||
* touching the radius, but an empty object is rejected — a mutation that
|
||||
* silently does nothing is indistinguishable from one that failed.
|
||||
*
|
||||
* `addressText` is a label a human typed, never geocoded: matching happens on
|
||||
* the coordinates alone, so an empty or wrong label costs nothing but clarity.
|
||||
*/
|
||||
export const updateLocationSchema = z
|
||||
.object({
|
||||
location: latLngSchema.optional(),
|
||||
addressText: z.string().trim().max(255).optional(),
|
||||
radiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M).optional(),
|
||||
})
|
||||
.refine((v) => Object.values(v).some((field) => field !== undefined), {
|
||||
message: 'Nothing to update',
|
||||
});
|
||||
export type UpdateLocationInput = z.infer<typeof updateLocationSchema>;
|
||||
|
||||
/**
|
||||
* A pro's own words for what they are good at.
|
||||
*
|
||||
* Deduplicated case-insensitively and server-side, keeping the first spelling:
|
||||
* "Boiler repair" and "boiler repair" are one skill to a reader, and letting
|
||||
* both through is how a card ends up padded with the same claim twice. Doing it
|
||||
* here rather than in the form means it holds for every caller.
|
||||
*/
|
||||
export const updateSkillsSchema = z.object({
|
||||
skills: z
|
||||
.array(z.string().trim().min(2).max(MAX_SKILL_LENGTH))
|
||||
.max(MAX_SKILLS)
|
||||
.transform((values) => {
|
||||
const seen = new Set<string>();
|
||||
return values.filter((value) => {
|
||||
const key = value.toLocaleLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}),
|
||||
});
|
||||
export type UpdateSkillsInput = z.input<typeof updateSkillsSchema>;
|
||||
|
||||
export const swipeSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
proId: z.string().uuid(),
|
||||
|
||||
Reference in New Issue
Block a user