All files / routes/pro/onboarding steps.routes.ts

100% Statements 37/37
100% Branches 16/16
100% Functions 3/3
100% Lines 37/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141                                                                                        1x                       18x 1x       17x   17x 17x   17x 11x 1x   10x   10x 18x   18x 10x 10x   10x 3x         10x 10x 10x     10x 10x     1x       1x       1x         12x 12x 12x 12x 12x     12x     11x   4x             8x 6x     8x       2x            
// Onboarding Step Save Routes
//
// Onboarding is ONE screen, and step 1 is the only step that exists.
//
// This file used to implement all seven cases of `handleStepSave` plus a
// sequential gate that refused step N until step N-1 had been recorded. None of
// cases 2-7 ever ran: the portal has only ever posted step 1 (see
// `apps/portal/src/pages/onboarding/index.tsx`) and the e2e suite only ever
// posted step 1. They were live dead code and were deleted in the portal
// redesign. The fields they wrote are all editable from the profile screens.
//
// `pros.onboarding_step` is NO LONGER WRITTEN — not here and not by
// `completion.routes.ts`. The column deliberately still exists: seeded and
// historical rows carry values and `GET /onboarding/status` still reads it for
// its `currentStep` field. Dropping it needs a migration plus a sweep of those
// readers, which is a separate change; removing the writes first is the
// reversible half. `onboardingStatus` (not the step number) is what decides
// whether onboarding is required — see `onboardingRequired` in
// `routes/pro/profile.routes.ts`.
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../../dal";
import { BadRequestError } from "../../../lib/errors";
import { handleError, success } from "../../../lib/response";
import { requireUser } from "../../../lib/utils";
import { requireProAccess } from "../../../middleware";
import type { Services } from "../../../services";
import { type Step1Data, step1Schema } from "./schemas";
import { flattenProForOnboarding } from "./utils";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
		proId: string;
		proRole: string;
	};
};
 
// The only step there is.
const ONLY_STEP = 1;
 
// Save the one onboarding step. Exported so it can be unit-tested directly;
// the step number is still a parameter (and still validated) because the route
// shape `/step/:stepNumber` is a published contract.
export async function handleStepSave(
	stepNumber: number,
	body: unknown,
	dal: Dal,
	proId: string,
	userId: string,
) {
	if (stepNumber !== ONLY_STEP) {
		throw new BadRequestError(`Invalid step number: ${stepNumber}`);
	}
 
	// Step 1: Simplified Business Info
	const data = step1Schema.parse(body) as Step1Data;
 
	const updateData: Record<string, unknown> = {};
	updateData.businessName = data.businessName;
	// Validate slug uniqueness
	const existingPro = await dal.pros.findBySlug(data.slug);
	if (existingPro && existingPro.id !== proId) {
		throw new BadRequestError("This slug is already taken");
	}
	updateData.slug = data.slug;
	// Empty string means "other" city — store as null
	updateData.cityId = data.cityId || null;
	updateData.businessTypeId = data.businessTypeId;
	// Sync whatsapp to both columns
	if (data.whatsapp) {
		updateData.whatsapp = data.whatsapp;
		updateData.whatsappBusinessNumber = data.whatsapp;
	}
	if (data.logoUrl !== undefined) {
		updateData.logoUrl = data.logoUrl || null;
	}
 
	// Mark onboarding as started. `onboardingStep` is deliberately not written
	// — see the file header.
	updateData.onboardingStatus = "in_progress";
	updateData.userUpdated = userId;
	updateData.dateUpdated = new Date();
 
	// Update pro (DAL takes 2 args: id, data)
	const pro = await dal.pros.update(proId, updateData);
	return pro;
}
 
const stepsRouter = new Hono<Env>();
 
// Save a step's data. Only step 1 exists, so anything else is a 400 at the
// edge rather than a silently-accepted no-op.
const saveStepParamsSchema = z.object({
	stepNumber: z.coerce.number().int().min(ONLY_STEP).max(ONLY_STEP),
});
 
stepsRouter.post(
	"/step/:stepNumber",
	requireProAccess,
	zValidator("param", saveStepParamsSchema),
	async (c) => {
		try {
			const dal = c.get("dal");
			const user = requireUser(c.get("user"));
			const proId = c.get("proId");
			const { stepNumber } = c.req.valid("param");
 
			// Parse request body
			const body = await c.req.json();
 
			// Handle step save with validation
			const pro = await handleStepSave(stepNumber, body, dal, proId, user.id);
 
			return success(c, {
				step: stepNumber,
				saved: true,
				pro: flattenProForOnboarding(pro as Record<string, unknown>),
			});
		} catch (err) {
			// Handle Zod validation errors
			if (err instanceof z.ZodError) {
				return handleError(
					c,
					new BadRequestError(
						`Validation failed: ${err.issues.map((e) => e.message).join(", ")}`,
					),
				);
			}
			return handleError(c, err);
		}
	},
);
 
export default stepsRouter;