Skip to content

Commit ad04208

Browse files
royalpinto007claude
andcommitted
fix: email storage, case number assignment, stale closure, comment validation
- Store submitter_email on post insert (was hardcoded null) - Assign APM-XXXX case number at approval time when not already set - Await approval email and return 207 on failure instead of silently swallowing - Use ref for authedPassword in admin useEffect to avoid stale closure - Validate that non-anonymous comments include a handle - Clean up search OR filter construction (typed, readable) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5bfad29 commit ad04208

5 files changed

Lines changed: 65 additions & 26 deletions

File tree

app/admin/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect, useCallback } from "react";
3+
import { useState, useEffect, useCallback, useRef } from "react";
44
import { Badge } from "@/components/ui/Badge";
55
import { Button } from "@/components/ui/Button";
66

@@ -64,6 +64,7 @@ function DamagePip({ level }: { level: number }) {
6464
export default function AdminPage() {
6565
const [password, setPassword] = useState("");
6666
const [authedPassword, setAuthedPassword] = useState("");
67+
const authedPasswordRef = useRef("");
6768
const [authError, setAuthError] = useState("");
6869
const [authenticated, setAuthenticated] = useState(false);
6970

@@ -116,9 +117,9 @@ export default function AdminPage() {
116117
const [initialAuthDone, setInitialAuthDone] = useState(false);
117118
useEffect(() => {
118119
if (authenticated && initialAuthDone) {
119-
fetchPosts(tab, authedPassword);
120+
fetchPosts(tab, authedPasswordRef.current);
120121
}
121-
}, [tab]); // eslint-disable-line react-hooks/exhaustive-deps
122+
}, [tab, authenticated, initialAuthDone, fetchPosts]);
122123

123124
async function handleAuth(e: React.FormEvent) {
124125
e.preventDefault();
@@ -129,6 +130,7 @@ export default function AdminPage() {
129130
if (res.ok) {
130131
const json = await res.json();
131132
setAuthedPassword(password);
133+
authedPasswordRef.current = password;
132134
setPosts(json.posts ?? []);
133135
setCounts(json.counts ?? { pending: 0, approved: 0, rejected: 0 });
134136
setInitialAuthDone(true);

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

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,33 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
3131

3232
const supabase = createSupabaseAdminClient();
3333

34+
// Assign case number on approval if not already set
35+
let caseNumber: string | null = null;
36+
if (parsed.data.status === "approved") {
37+
const { data: existing } = await supabase
38+
.from("posts")
39+
.select("case_number")
40+
.eq("id", params.id)
41+
.single();
42+
43+
if (!existing?.case_number) {
44+
const { count } = await supabase
45+
.from("posts")
46+
.select("*", { count: "exact", head: true })
47+
.eq("status", "approved");
48+
const seq = ((count ?? 0) + 1).toString().padStart(4, "0");
49+
caseNumber = `APM-${seq}`;
50+
}
51+
}
52+
53+
const updatePayload =
54+
caseNumber !== null
55+
? { status: parsed.data.status, case_number: caseNumber }
56+
: { status: parsed.data.status };
57+
3458
const { data, error } = await supabase
3559
.from("posts")
36-
.update({ status: parsed.data.status })
60+
.update(updatePayload)
3761
.eq("id", params.id)
3862
.select("id, status, case_number, submitter_email, title")
3963
.single();
@@ -48,14 +72,20 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
4872

4973
if (parsed.data.status === "approved" && data.submitter_email) {
5074
const caseUrl = `https://agentpostmortem.com/case/${data.case_number}`;
51-
sendApprovalEmail({
52-
to: data.submitter_email,
53-
caseNumber: data.case_number,
54-
caseTitle: data.title,
55-
caseUrl,
56-
}).catch((err) => {
75+
try {
76+
await sendApprovalEmail({
77+
to: data.submitter_email,
78+
caseNumber: data.case_number,
79+
caseTitle: data.title,
80+
caseUrl,
81+
});
82+
} catch (err) {
5783
console.error("[admin/posts/id] approval email failed:", err);
58-
});
84+
return NextResponse.json(
85+
{ error: "Post approved but approval email failed to send.", data },
86+
{ status: 207 },
87+
);
88+
}
5989
}
6090

6191
return NextResponse.json(data);

app/api/comments/route.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ export async function POST(req: NextRequest) {
4848
);
4949
}
5050

51+
const isAnon = is_anonymous ?? true;
52+
const handle =
53+
typeof author_handle === "string" ? author_handle.trim() : null;
54+
if (!isAnon && !handle) {
55+
return NextResponse.json(
56+
{ error: "A handle is required when posting non-anonymously." },
57+
{ status: 400 },
58+
);
59+
}
60+
5161
const ip = getIp(req);
5262
const ip_hash = hashIp(ip);
5363

@@ -70,8 +80,8 @@ export async function POST(req: NextRequest) {
7080
.insert({
7181
post_id,
7282
body: trimmed,
73-
is_anonymous: is_anonymous ?? true,
74-
author_handle: is_anonymous ? null : (author_handle ?? null),
83+
is_anonymous: isAnon,
84+
author_handle: isAnon ? null : handle,
7585
ip_hash,
7686
status: "visible",
7787
})

app/api/posts/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
110110
screenshot_urls: data.screenshotUrls ?? [],
111111
is_anonymous: data.isAnonymous,
112112
submitter_handle: cleanHandle,
113-
submitter_email: null,
113+
submitter_email: data.email ?? null,
114114
edit_token_hash: tokenHash,
115115
ip_hash: ipHash,
116116
status: "pending",

lib/db/posts.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -224,20 +224,17 @@ export async function fetchSearchPosts(
224224
.ilike("name", `%${query}%`);
225225
const agentIds = (agentRows ?? []).map((a) => (a as { id: string }).id);
226226

227-
let q = supabase
227+
const textFilter = `title.ilike.%${query}%,outcome.ilike.%${query}%,case_number.ilike.%${query}%`;
228+
const orFilter =
229+
agentIds.length > 0
230+
? `${textFilter},agent_id.in.(${agentIds.join(",")})`
231+
: textFilter;
232+
233+
const q = supabase
228234
.from("posts")
229235
.select(`*, agents(slug, name, company), post_tags(tags(slug, label))`)
230-
.eq("status", "approved");
231-
232-
if (agentIds.length > 0) {
233-
q = q.or(
234-
`title.ilike.%${query}%,outcome.ilike.%${query}%,case_number.ilike.%${query}%,agent_id.in.(${agentIds.join(",")})`,
235-
);
236-
} else {
237-
q = q.or(
238-
`title.ilike.%${query}%,outcome.ilike.%${query}%,case_number.ilike.%${query}%`,
239-
);
240-
}
236+
.eq("status", "approved")
237+
.or(orFilter);
241238

242239
const { data, error } = await q
243240
.order("vote_score", { ascending: false })

0 commit comments

Comments
 (0)