File size: 3,288 Bytes
78664f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * 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;
}