Skip to content

Commit af8b5fd

Browse files
committed
fix(admin): derive case numbers from the highest number on file
Approval minted the next case number from a count of approved posts, so any retired case silently freed its number for reuse. With 43 approved posts and APM-0009 through APM-0036 retired, the next approval would have minted APM-0044, which already exists. The read-then-write also raced: two concurrent approvals both read the same value and mint the same number. Case numbers now come from the maximum numeric suffix across every row that has one, including retired and rejected cases, so a number is never handed out twice. The write retries on a unique violation with a bounded budget, re-deriving the number from scratch on each attempt. Format stays APM-XXXX, zero padded. Fixes #49
1 parent 6262dae commit af8b5fd

4 files changed

Lines changed: 528 additions & 21 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { NextRequest } from "next/server";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const from = vi.fn();
5+
const sendApprovalEmail = vi.fn();
6+
7+
vi.mock("@/lib/supabase/admin", () => ({
8+
createSupabaseAdminClient: () => ({ from }),
9+
}));
10+
11+
vi.mock("@/lib/resend/send", () => ({
12+
sendApprovalEmail: (...args: unknown[]) => sendApprovalEmail(...args),
13+
}));
14+
15+
interface PostsTableOptions {
16+
/** Case number already on the post being approved, if any. */
17+
existingCaseNumber?: string | null;
18+
/** Every case number on file, including retired and rejected ones. */
19+
storedCaseNumbers: string[];
20+
/**
21+
* Case numbers the unique index will reject on write. They become visible
22+
* to reads only after the first one, mimicking a concurrent approval that
23+
* lands between our read and our write.
24+
*/
25+
taken?: Set<string>;
26+
}
27+
28+
const attemptedCaseNumbers: string[] = [];
29+
30+
function mockPostsTable(options: PostsTableOptions) {
31+
const taken = options.taken ?? new Set<string>();
32+
let reads = 0;
33+
34+
from.mockImplementation((table: string) => {
35+
if (table !== "posts") throw new Error(`Unexpected table ${table}`);
36+
37+
return {
38+
select: (columns: string) => ({
39+
// Lookup of the post's current case number.
40+
eq: () => ({
41+
single: async () => ({
42+
data: { case_number: options.existingCaseNumber ?? null },
43+
error: null,
44+
}),
45+
}),
46+
// Listing of all case numbers on file.
47+
not: async () => {
48+
reads += 1;
49+
const visible =
50+
reads === 1
51+
? options.storedCaseNumbers
52+
: [...options.storedCaseNumbers, ...Array.from(taken)];
53+
return {
54+
data: visible.map((case_number) => ({ case_number })),
55+
error: null,
56+
};
57+
},
58+
columns,
59+
}),
60+
update: (payload: { status: string; case_number?: string }) => ({
61+
eq: () => ({
62+
select: () => ({
63+
single: async () => {
64+
const caseNumber = payload.case_number ?? null;
65+
if (caseNumber) attemptedCaseNumbers.push(caseNumber);
66+
if (caseNumber && taken.has(caseNumber)) {
67+
return { data: null, error: { code: "23505" } };
68+
}
69+
if (caseNumber) taken.add(caseNumber);
70+
return {
71+
data: {
72+
id: "post-1",
73+
status: payload.status,
74+
case_number: caseNumber ?? options.existingCaseNumber ?? null,
75+
submitter_email: null,
76+
title: "A case",
77+
},
78+
error: null,
79+
};
80+
},
81+
}),
82+
}),
83+
}),
84+
};
85+
});
86+
}
87+
88+
function patchRequest(status: string) {
89+
return new NextRequest("http://localhost/api/admin/posts/post-1", {
90+
method: "PATCH",
91+
headers: {
92+
"content-type": "application/json",
93+
"x-admin-password": "secret",
94+
},
95+
body: JSON.stringify({ status }),
96+
});
97+
}
98+
99+
describe("PATCH /api/admin/posts/[id]", () => {
100+
const originalPassword = process.env.ADMIN_PASSWORD;
101+
102+
beforeEach(() => {
103+
vi.resetModules();
104+
vi.clearAllMocks();
105+
attemptedCaseNumbers.length = 0;
106+
process.env.ADMIN_PASSWORD = "secret";
107+
});
108+
109+
afterEach(() => {
110+
process.env.ADMIN_PASSWORD = originalPassword;
111+
});
112+
113+
it("rejects requests without the admin password", async () => {
114+
const { PATCH } = await import("./route");
115+
const response = await PATCH(
116+
new NextRequest("http://localhost/api/admin/posts/post-1", {
117+
method: "PATCH",
118+
headers: { "content-type": "application/json" },
119+
body: JSON.stringify({ status: "approved" }),
120+
}),
121+
{ params: { id: "post-1" } },
122+
);
123+
124+
expect(response.status).toBe(401);
125+
});
126+
127+
it("assigns the next case number above the highest on file", async () => {
128+
mockPostsTable({ storedCaseNumbers: ["APM-0001", "APM-0043"] });
129+
130+
const { PATCH } = await import("./route");
131+
const response = await PATCH(patchRequest("approved"), {
132+
params: { id: "post-1" },
133+
});
134+
135+
expect(response.status).toBe(200);
136+
await expect(response.json()).resolves.toMatchObject({
137+
case_number: "APM-0044",
138+
});
139+
});
140+
141+
it("never reuses a retired case number", async () => {
142+
// 43 approved cases, but APM-0009..APM-0036 were retired. A count based
143+
// scheme would mint APM-0044, which already exists.
144+
const stored = [
145+
...Array.from(
146+
{ length: 8 },
147+
(_, i) => `APM-${(i + 1).toString().padStart(4, "0")}`,
148+
),
149+
...Array.from(
150+
{ length: 35 },
151+
(_, i) => `APM-${(i + 37).toString().padStart(4, "0")}`,
152+
),
153+
];
154+
mockPostsTable({ storedCaseNumbers: stored });
155+
156+
const { PATCH } = await import("./route");
157+
const response = await PATCH(patchRequest("approved"), {
158+
params: { id: "post-1" },
159+
});
160+
161+
const body = (await response.json()) as { case_number: string };
162+
expect(stored).toContain("APM-0044");
163+
expect(stored).not.toContain(body.case_number);
164+
expect(body.case_number).toBe("APM-0072");
165+
});
166+
167+
it("retries when a concurrent approval takes the number first", async () => {
168+
mockPostsTable({
169+
storedCaseNumbers: ["APM-0043"],
170+
taken: new Set(["APM-0044"]),
171+
});
172+
173+
const { PATCH } = await import("./route");
174+
const response = await PATCH(patchRequest("approved"), {
175+
params: { id: "post-1" },
176+
});
177+
178+
expect(response.status).toBe(200);
179+
expect(attemptedCaseNumbers).toEqual(["APM-0044", "APM-0045"]);
180+
await expect(response.json()).resolves.toMatchObject({
181+
case_number: "APM-0045",
182+
});
183+
});
184+
185+
it("leaves an existing case number alone", async () => {
186+
mockPostsTable({
187+
existingCaseNumber: "APM-0005",
188+
storedCaseNumbers: ["APM-0005", "APM-0043"],
189+
});
190+
191+
const { PATCH } = await import("./route");
192+
const response = await PATCH(patchRequest("approved"), {
193+
params: { id: "post-1" },
194+
});
195+
196+
expect(response.status).toBe(200);
197+
expect(attemptedCaseNumbers).toEqual([]);
198+
});
199+
200+
it("does not assign a case number on rejection", async () => {
201+
mockPostsTable({ storedCaseNumbers: ["APM-0043"] });
202+
203+
const { PATCH } = await import("./route");
204+
const response = await PATCH(patchRequest("rejected"), {
205+
params: { id: "post-1" },
206+
});
207+
208+
expect(response.status).toBe(200);
209+
expect(attemptedCaseNumbers).toEqual([]);
210+
});
211+
});

app/api/admin/posts/[id]/route.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod";
33
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
44
import { sendApprovalEmail } from "@/lib/resend/send";
55
import { getSiteUrl } from "@/lib/utils/urls";
6+
import { updateWithFreshCaseNumber } from "@/lib/db/case-number";
67

78
function checkAdminAuth(req: NextRequest): boolean {
89
const auth = req.headers.get("x-admin-password");
@@ -32,36 +33,44 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
3233

3334
const supabase = createSupabaseAdminClient();
3435

35-
// Assign case number on approval if not already set
36-
let caseNumber: string | null = null;
36+
// Does this post still need a case number?
37+
let needsCaseNumber = false;
3738
if (parsed.data.status === "approved") {
3839
const { data: existing } = await supabase
3940
.from("posts")
4041
.select("case_number")
4142
.eq("id", params.id)
4243
.single();
43-
44-
if (!existing?.case_number) {
45-
const { count } = await supabase
46-
.from("posts")
47-
.select("*", { count: "exact", head: true })
48-
.eq("status", "approved");
49-
const seq = ((count ?? 0) + 1).toString().padStart(4, "0");
50-
caseNumber = `APM-${seq}`;
51-
}
44+
needsCaseNumber = !existing?.case_number;
5245
}
5346

54-
const updatePayload =
55-
caseNumber !== null
56-
? { status: parsed.data.status, case_number: caseNumber }
57-
: { status: parsed.data.status };
47+
const runUpdate = (payload: {
48+
status: "approved" | "rejected";
49+
case_number?: string;
50+
}) =>
51+
supabase
52+
.from("posts")
53+
.update(payload)
54+
.eq("id", params.id)
55+
.select("id, status, case_number, submitter_email, title")
56+
.single();
5857

59-
const { data, error } = await supabase
60-
.from("posts")
61-
.update(updatePayload)
62-
.eq("id", params.id)
63-
.select("id, status, case_number, submitter_email, title")
64-
.single();
58+
// Case numbers are permanent, so the next one comes from the highest number
59+
// on file rather than a count of approved posts. Retired numbers are never
60+
// handed out again, and concurrent approvals retry on unique violations.
61+
const { data, error } = needsCaseNumber
62+
? await updateWithFreshCaseNumber({
63+
listCaseNumbers: async () => {
64+
const { data: rows } = await supabase
65+
.from("posts")
66+
.select("case_number")
67+
.not("case_number", "is", null);
68+
return (rows ?? []).map((row) => row.case_number);
69+
},
70+
update: (caseNumber) =>
71+
runUpdate({ status: parsed.data.status, case_number: caseNumber }),
72+
})
73+
: await runUpdate({ status: parsed.data.status });
6574

6675
if (error || !data) {
6776
console.error("[admin/posts/id] update error:", error);

0 commit comments

Comments
 (0)