helo-ayush's picture
feat: deploy SIHJantaParty Realtime Chat & Events Server (Node 20 Docker)
78664f6
Raw History Blame Contribute Delete
3.29 kB
/**
* 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<T>` 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<T> =
| { ok: true; data: T }
| { ok: false; fail: Fail; hint?: string };
export const ok = <T>(data: T): Result<T> => ({ ok: true, data });
export const fail = (f: Fail, hint?: string): Result<never> => ({
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<Fail, string> = {
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<string, unknown> };
return err?.code === 11000 ? Object.keys(err.keyPattern ?? {}) : null;
}