/** * SIHJantaParty — typed failure vocabulary shared by the DAL, Server Actions * and the UI. * * CLIENT-SAFE by design: no mongoose import, no `server-only`. The UI needs * FAIL_COPY to render *why* an action failed, so this module must be importable * from both sides of the boundary. * * The rule this file exists to enforce: never `throw` for an expected * precondition failure across a Server Action boundary. A thrown error reaches * the production client as an opaque digest with the message stripped, so the * UI physically cannot explain itself. Return `Result` instead and throw * only for genuine bugs. */ export type Fail = | "UNAUTHENTICATED" | "NOT_FOUND" | "CONFLICT" | "INVALID" | "NOT_LEADER" | "LEADER_MUST_TRANSFER" | "TARGET_NOT_MEMBER" | "NOT_MEMBER" | "TEAM_FULL" | "REQUEST_NOT_PENDING" | "APPLICANT_HAS_TEAM" | "ALREADY_ON_TEAM" | "NAME_TAKEN" | "DUPLICATE_REQUEST" | "SELF_TARGET" | "TEAM_CLOSED" | "CHAT_LOCKED"; export type Result = | { ok: true; data: T } | { ok: false; fail: Fail; hint?: string }; export const ok = (data: T): Result => ({ ok: true, data }); export const fail = (f: Fail, hint?: string): Result => ({ ok: false, fail: f, hint, }); /** * Thrown *inside* the DAL only, to unwind a transaction with a specific reason. * Every DAL entry point catches it and converts to `Result`. It must never * escape to a Server Action's caller. */ export class Precondition extends Error { constructor( readonly fail: Fail, readonly hint?: string ) { super(fail); this.name = "Precondition"; } } /** Human-readable copy for every failure. Rendered directly by the UI. */ export const FAIL_COPY: Record = { UNAUTHENTICATED: "Please sign in again.", NOT_FOUND: "That no longer exists.", CONFLICT: "Someone else changed this a moment ago — reload and retry.", INVALID: "That input isn't valid.", NOT_LEADER: "Only the current leader can do that.", LEADER_MUST_TRANSFER: "You lead this squad — hand leadership to a teammate before you leave.", TARGET_NOT_MEMBER: "You can only do that to someone already on your team.", NOT_MEMBER: "You're not on that team.", TEAM_FULL: "This squad already has all 6 seats filled — the last one went while you were deciding.", REQUEST_NOT_PENDING: "That request was already answered or withdrawn.", APPLICANT_HAS_TEAM: "This student joined another team first.", ALREADY_ON_TEAM: "You're already on a team.", NAME_TAKEN: "That name is taken (names ignore case and spacing).", DUPLICATE_REQUEST: "You already have a live request with this team.", SELF_TARGET: "You can't do that to yourself.", TEAM_CLOSED: "This squad isn't recruiting any more.", CHAT_LOCKED: "That chat hasn't been unlocked yet.", }; /** * Returns the offending index's key paths on an E11000 duplicate-key error, or * null if `e` is any other error. Lets a caller tell *which* unique index it * collided with — `nameKey` (team name taken) vs the request triple, etc. */ export function dupKeyOn(e: unknown): string[] | null { const err = e as { code?: number; keyPattern?: Record }; return err?.code === 11000 ? Object.keys(err.keyPattern ?? {}) : null; }