import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { CategoryNotFoundError, CategoryNotOptOutAbleError, ChannelNotFoundError, ForbiddenError, MissingRequiredFieldError, } from "../lib/errors.generated"; export interface UpdateNotificationPreferenceInput { userId: string; categoryId: string; channelId: string; allowed: boolean; } /** * Upserts a single caller NotificationPreference row after validating self-service access, category/channel existence, and opt-out eligibility. */ export async function run( db: Transaction, input: UpdateNotificationPreferenceInput, ctx: CommandContext, ) { const { userId, categoryId, channelId, allowed } = input; // Required-field validation precedes the authz/existence checks so a blank key // is reported as MISSING_REQUIRED_FIELD rather than leaking out as FORBIDDEN // (blank userId) or CATEGORY_NOT_FOUND / CHANNEL_NOT_FOUND (blank id). if (typeof userId !== "string" || userId.trim() === "") { return err(new MissingRequiredFieldError("userId")); } if (typeof categoryId !== "string" || categoryId.trim() === "") { return err(new MissingRequiredFieldError("categoryId")); } if (typeof channelId !== "string" || channelId.trim() === "") { return err(new MissingRequiredFieldError("channelId")); } if (typeof allowed !== "boolean") { return err(new MissingRequiredFieldError("allowed")); } if (ctx.actorId !== userId) { return err(new ForbiddenError(userId)); } const category = await db .selectFrom("NotificationCategory") .selectAll() .where("id", "=", categoryId) .executeTakeFirst(); if (!category) { return err(new CategoryNotFoundError(categoryId)); } const channel = await db .selectFrom("NotificationChannel") .selectAll() .where("id", "=", channelId) .executeTakeFirst(); if (!channel) { return err(new ChannelNotFoundError(channelId)); } // Transactional invariant: opt-out forbidden on optOutAllowed=false categories. if (!category.optOutAllowed && !allowed) { return err(new CategoryNotOptOutAbleError(categoryId)); } const existing = await db .selectFrom("NotificationPreference") .selectAll() .where("userId", "=", userId) .where("categoryId", "=", categoryId) .where("channelId", "=", channelId) .forUpdate() .executeTakeFirst(); const now = new Date(); if (existing) { const updated = await db .updateTable("NotificationPreference") .set({ allowed, updatedAt: now }) .where("id", "=", existing.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ preference: updated }); } const inserted = await db .insertInto("NotificationPreference") .values({ id: crypto.randomUUID(), userId, categoryId, channelId, allowed, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ preference: inserted }); }