import { HttpError } from "@batch-cooking/error-tools"; import { ErrorCode, type SafeUserProfile } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; import { analytics } from "../../lib/analytics.service.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; /** * Sets (or clears, if `dietId` is `null`) a profile's dietary regime — the * regime step of the profile journey is skippable, so `null` is a normal, * valid value, not an omission to reject. * * @throws {HttpError} `404 DIET_NOT_FOUND` if `dietId` doesn't match a reference `Diet` row. */ export async function updateDiet( userProfileId: number, dietId: number | null, ): Promise { try { if (dietId !== null) { const diet = await prisma.diet.findUnique({ where: { id: dietId } }); if (!diet) { throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`); } } const profile = await prisma.userProfile.update({ where: { id: userProfileId }, data: { dietId }, }); return toSafeProfile(profile); } catch (err) { // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which // already logs it, see `error-logger.ts`) is what actually handles it, // this service layer just isn't allowed a bare `await` per the repo's // async/try-catch convention. throw err; } } /** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */ export async function getAllergyIds(userProfileId: number): Promise { try { const rows = await prisma.userProfileAllergy.findMany({ where: { userProfileId }, select: { allergyId: true }, }); return rows.map((row) => row.allergyId); } catch (err) { throw err; // see updateDiet()'s catch comment above } } /** * Replaces a profile's full allergen set (not a merge — the caller sends * the complete list every time, same shape the multi-select UI already * holds). Validates every id up front so a partially-invalid request never * leaves the set half-updated. * * @throws {HttpError} `404 ALLERGY_NOT_FOUND` if any `allergyId` doesn't match a reference `Allergy` row. */ export async function updateAllergies( userProfileId: number, allergyIds: number[], ): Promise { try { if (allergyIds.length > 0) { const found = await prisma.allergy.findMany({ where: { id: { in: allergyIds } }, select: { id: true }, }); const foundIds = new Set(found.map((allergy) => allergy.id)); const missing = allergyIds.filter((id) => !foundIds.has(id)); if (missing.length > 0) { throw new HttpError( 404, ErrorCode.ALLERGY_NOT_FOUND, `Unknown allergy id(s): ${missing.join(", ")}`, ); } } await prisma.$transaction([ prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }), prisma.userProfileAllergy.createMany({ data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })), }), ]); return allergyIds; } catch (err) { throw err; // see updateDiet()'s catch comment above } } /** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */ export async function getDislikedIngredientIds(userProfileId: number): Promise { try { const rows = await prisma.userProfileDislikedIngredient.findMany({ where: { userProfileId }, select: { ingredientId: true }, }); return rows.map((row) => row.ingredientId); } catch (err) { throw err; // see updateDiet()'s catch comment above } } /** * Replaces a profile's full disliked-ingredient set (not a merge — the * caller sends the complete list every time, same contract as * {@link updateAllergies}). * * @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference `Ingredient` row. */ export async function updateDislikedIngredients( userProfileId: number, dislikedIngredientIds: number[], ): Promise { try { if (dislikedIngredientIds.length > 0) { const found = await prisma.ingredient.findMany({ where: { id: { in: dislikedIngredientIds } }, select: { id: true }, }); const foundIds = new Set(found.map((ingredient) => ingredient.id)); const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id)); if (missing.length > 0) { throw new HttpError( 404, ErrorCode.INGREDIENT_NOT_FOUND, `Unknown ingredient id(s): ${missing.join(", ")}`, ); } } await prisma.$transaction([ prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId }, }), prisma.userProfileDislikedIngredient.createMany({ data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId, })), }), ]); return dislikedIngredientIds; } catch (err) { throw err; // see updateDiet()'s catch comment above } } /** * The `key`s of every guided-tour scenario this profile has already seen * (completed or skipped — both leave a `TourProgress` row). The web tour * engine reads this once per session to decide which tours to auto-start * (issues #42/#44). */ export async function getCompletedTourKeys(userProfileId: number): Promise { try { const rows = await prisma.tourProgress.findMany({ where: { userProfileId }, select: { scenario: { select: { key: true } } }, }); return rows.map((row) => row.scenario.key); } catch (err) { throw err; // see updateDiet()'s catch comment above } } /** * Marks a guided-tour scenario as seen by this profile — idempotent upsert, * safe to call again (e.g. the engine retries after a flaky network). Emits * `tour.completed` or `tour.skipped` for the admin dashboard (issue #44's * analytics — the dashboard renders any new `AnalyticsEvent` type * generically, no frontend change needed). * * @throws {HttpError} `404 TOUR_NOT_FOUND` if `tourKey` doesn't match an * **enabled** `TourScenario` (a disabled tour can't be completed). */ export async function markTourSeen( userProfileId: number, tourKey: string, skipped: boolean, ): Promise { try { const scenario = await prisma.tourScenario.findUnique({ where: { key: tourKey } }); if (!scenario?.enabled) { throw new HttpError(404, ErrorCode.TOUR_NOT_FOUND, `No enabled tour with key ${tourKey}`); } await prisma.tourProgress.upsert({ where: { userProfileId_scenarioId: { userProfileId, scenarioId: scenario.id } }, update: {}, create: { userProfileId, scenarioId: scenario.id }, }); analytics.recordEvent(skipped ? "tour.skipped" : "tour.completed", { actorId: userProfileId, context: { tourKey }, }); } catch (err) { throw err; // see updateDiet()'s catch comment above } }