fix(comments) : show notified threads in inbox and stop edit notifications

This commit is contained in:
2026-10-12 23:03:17 +07:00 Unverified
parent 1fe35546d3
commit 05350e7aab
8 changed files with 316 additions and 35 deletions
+1
View File
@@ -182,6 +182,7 @@ also receive new top-level comments from other authors, with a link that opens t
comment in an admin conversation dialog. Admins can enable this from the inbox
using “แจ้งเตือนความคิดเห็นใหม่”. Reply notification clicks open the
root thread, load the relevant reply page, and highlight/focus the exact reply.
Editing a comment or reply does not create bell or browser push notifications.
This uses the existing `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, and
`WEB_PUSH_SUBJECT` configuration, with subscriptions in `comments.push_subscription`.
The header bell lists comments and Commission notifications from `public.notification`,
-3
View File
@@ -1,5 +1,3 @@
import { after } from "next/server";
import { sendCommentPush } from "@/lib/comments/push";
import { authorizeComment, commentId, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository";
import { publishComment } from "@/lib/comments/publish";
import { errorResponse, requireSameOrigin } from "@/lib/security/http";
@@ -21,7 +19,6 @@ export async function PATCH(request: Request, context: Context) {
const viewer = await requireCommentViewer();
await limitRequest("comment-publish", viewer.id, 20);
const result = await publishComment(request, viewer, { id: commentId((await context.params).id) });
after(async () => { await sendCommentPush(result.id, result.version).catch(() => console.error("Comment notification failed")); });
return Response.json(result);
} catch (cause) { return errorResponse(cause); }
}
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommentItem, CommentPage } from "@/lib/comments/types";
const events = vi.hoisted(() => ({ refresh: undefined as undefined | (() => Promise<unknown>) }));
vi.mock("./use-comment-events", () => ({ useCommentEvents: (_scope: unknown, refresh: () => Promise<unknown>) => { events.refresh = refresh; } }));
vi.mock("./comment-card", () => ({
CommentCard: ({ item, defaultExpanded, onChange, overview }: { item: CommentItem; defaultExpanded: boolean; onChange: () => Promise<unknown>; overview: React.ReactNode }) =>
<tr id={`comment-${item.id}`} data-expanded={defaultExpanded}><td>{item.text}<button onClick={() => void onChange()}>Update {item.id}</button></td><td>{overview}</td></tr>,
}));
import { AdminCommentInbox } from "./admin-comment-inbox";
const viewer = { id: "admin", admin: true, name: "Admin" };
const target = "stygian:test";
const selectedId = "00000000-0000-4000-8000-000000000001";
function comment(id: string, text = id): CommentItem {
return {
id, rootId: null, replyToId: null, replyToName: null,
authorName: "Author", authorImage: null, authorAdmin: false, text, images: [], version: 1,
createdAt: "2026-10-08T12:00:00Z", editedAt: "2026-10-08T12:00:00Z",
hidden: false, ownHidden: false, deleted: false, hearted: false, likes: 0, reaction: 0,
replyCount: 1, hasReplies: true, canEdit: true, canInteract: true, canModerate: true,
target, targetName: "Test round", targetImage: null, href: "/stygian/comment?schedule=test",
};
}
function page(items: CommentItem[], nextCursor: string | null = null): CommentPage { return { items, nextCursor, viewer }; }
let container: HTMLDivElement;
let root: Root;
let inbox: CommentPage;
let nextPage: CommentPage;
let selected: CommentItem;
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
events.refresh = undefined;
selected = comment(selectedId, "Selected conversation");
inbox = page([comment("newer", "Newer conversation")]);
nextPage = page([]);
fetchMock.mockReset();
fetchMock.mockImplementation(async (input) => {
const url = new URL(String(input), "https://guide.sudloh.com");
return Response.json(url.pathname === "/api/admin/comments" ? url.searchParams.has("cursor") ? nextPage : inbox : { item: selected, viewer });
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
async function mount(initial = inbox, notification: string | undefined = selectedId) {
await act(async () => root.render(<AdminCommentInbox initial={initial} initialTarget={target} initialComment={notification}
targets={[{ value: target, label: "Test round", image: null, rarity: null, characterKey: null }]} />));
}
function rows() { return [...container.querySelectorAll("tbody tr[id]")].map((row) => row.id); }
async function click(label: string) {
const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.trim() === label);
expect(button).toBeDefined();
await act(async () => button!.click());
}
function singleRequests() { return fetchMock.mock.calls.filter(([url]) => String(url) === `/api/comments/${selectedId}`); }
describe("notification conversations in the admin inbox", () => {
it("moves a loaded conversation to the first normal row, expands it, and avoids a second lookup", async () => {
await mount(page([...inbox.items, selected]));
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
expect(container.querySelector(`#comment-${selectedId}`)?.getAttribute("data-expanded")).toBe("true");
expect(container.querySelector(`#comment-${selectedId} td:last-child a`)?.textContent).toBe("Test round");
expect(container.textContent).not.toContain("การสนทนาจากการแจ้งเตือน");
expect(singleRequests()).toHaveLength(0);
});
it("loads an older conversation and deduplicates it when pagination catches up", async () => {
await mount(page(inbox.items, "next-page"));
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
expect(singleRequests()).toHaveLength(1);
nextPage = page([comment(selectedId, "Fresh page content"), comment("oldest")]);
await click("ดูความคิดเห็นเพิ่มเติม");
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer", "comment-oldest"]);
expect(container.textContent).toContain("Fresh page content");
expect(singleRequests()).toHaveLength(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("cursor=next-page"))).toBe(true);
});
it("keeps an older conversation fresh after mutations, manual refresh, and live updates", async () => {
await mount();
selected = comment(selectedId, "After reply or moderation");
await click(`Update ${selectedId}`);
expect(container.textContent).toContain("After reply or moderation");
selected = comment(selectedId, "After manual refresh");
await click("รีเฟรช");
expect(container.textContent).toContain("After manual refresh");
selected = comment(selectedId, "After live update");
await act(async () => { await events.refresh!(); });
expect(container.textContent).toContain("After live update");
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it.each(["visibility", "answered", "target"])("clears the notification selection when the %s filter changes", async (filter) => {
await mount();
inbox = page([comment("filtered")]);
if (filter === "target") {
const clear = container.querySelector<HTMLButtonElement>('[data-slot="combobox-clear"]');
expect(clear).not.toBeNull();
await act(async () => clear!.click());
} else await click(filter === "visibility" ? "ซ่อนอยู่" : "ยังไม่ได้ตอบ");
expect(rows()).toEqual(["comment-filtered"]);
expect(singleRequests()).toHaveLength(1);
const query = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
if (filter === "target") expect(query.has("target")).toBe(false);
else expect(query.get(filter === "visibility" ? "status" : "unanswered")).toBe(filter === "visibility" ? "hidden" : "true");
});
it("shows loading without an empty state and ignores a late lookup after filtering", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementationOnce(() => new Promise<Response>((done) => { resolve = done; }));
await mount(page([]));
expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.textContent).not.toContain("ไม่พบความคิดเห็นที่ตรงกัน");
const signal = singleRequests()[0][1]?.signal;
await click("ซ่อนอยู่");
expect(signal?.aborted).toBe(true);
await act(async () => resolve(Response.json({ item: selected, viewer })));
expect(rows()).toEqual(["comment-newer"]);
});
it.each(["missing", "wrong target", "reply"])("shows an inline error for a %s conversation while keeping the list usable", async (failure) => {
if (failure === "missing") fetchMock.mockResolvedValueOnce(Response.json({ error: "comment-not-found" }, { status: 404 }));
else selected = { ...selected, ...(failure === "reply" ? { rootId: "another-root" } : { target: "guide:other" }) };
await mount();
expect(container.querySelector('[role="alert"]')).not.toBeNull();
expect(rows()).toEqual(["comment-newer"]);
expect(container.querySelector('[role="status"]')).toBeNull();
selected = comment(selectedId, "Recovered conversation");
await click("รีเฟรช");
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it("preserves the normal inbox order without a notification", async () => {
await mount(page([comment("newer"), selected]), "");
expect(rows()).toEqual(["comment-newer", `comment-${selectedId}`]);
expect(singleRequests()).toHaveLength(0);
});
});
+35 -12
View File
@@ -12,11 +12,12 @@ import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxI
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { CommentPage } from "@/lib/comments/types";
import type { CommentItem, CommentPage } from "@/lib/comments/types";
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
import { useMarkCommentsRead } from "./admin-unread-comments";
import { CommentCard, CommentThread } from "./comment-card";
import { CommentCard } from "./comment-card";
import { commentRequest } from "./client";
import { AdminCommentRowsSkeleton } from "./comment-skeleton";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
@@ -26,42 +27,64 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
const [status, setStatus] = useState("all");
const [answered, setAnswered] = useState("all");
const [selectedId, setSelectedId] = useState(initialTarget ? initialComment : undefined);
const [selectedResult, setSelectedResult] = useState<{ item: CommentItem | null; error: string }>({ item: null, error: "" });
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, unanswered: String(answered === "unanswered") });
if (target) query.set("target", target.value);
const feed = useCommentFeed(`/api/admin/comments?${query}`, initial);
const loadedSelection = feed.page.items.find((item) => item.id === selectedId && item.target === initialTarget && !item.rootId);
const selectionLoaded = Boolean(loadedSelection);
useEffect(() => {
if (!selectedId || !initialTarget || selectionLoaded) return;
const controller = new AbortController();
void Promise.resolve().then(async () => {
if (controller.signal.aborted) return;
setSelectedResult((current) => ({ ...current, error: "" }));
try {
const result = await commentRequest<{ item: CommentItem }>(`/api/comments/${selectedId}`, { signal: controller.signal });
if (!result.item || result.item.id !== selectedId || result.item.target !== initialTarget || result.item.rootId) throw new Error("ไม่พบการสนทนาที่เลือก");
if (!controller.signal.aborted) setSelectedResult({ item: result.item, error: "" });
} catch (cause) {
if (!controller.signal.aborted) setSelectedResult({ item: null, error: cause instanceof Error ? cause.message : "ไม่สามารถโหลดการสนทนาได้" });
}
});
return () => controller.abort();
}, [selectedId, initialTarget, selectionLoaded, feed.revision]);
const selectedItem = selectedId ? loadedSelection ?? selectedResult.item : null;
const selectedError = selectedId && !selectionLoaded ? selectedResult.error : "";
const selectedBusy = Boolean(selectedId && !selectedItem && !selectedError);
const items = selectedItem ? [selectedItem, ...feed.page.items.filter((item) => item.id !== selectedId)] : feed.page.items;
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true }));
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
return <div className="flex min-w-0 flex-col gap-6 max-md:[&_button]:min-h-11 max-md:[&_button]:min-w-11">
{initialComment && initialTarget && <section className="flex min-w-0 flex-col gap-3" aria-label={"การสนทนาจากการแจ้งเตือน"}>
<h2 className="font-heading text-lg font-semibold">{"การสนทนาจากการแจ้งเตือน"}</h2>
<CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />
</section>}
<FieldGroup className="lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">
<Field><FieldLabel>{"ไกด์หรือรอบ Stygian"}</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
<FuzzyCombobox items={targets} value={target} onValueChange={(value) => { if (value?.value !== target?.value) setSelectedId(undefined); setTarget(value); }} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
searchNames={(item) => [item.label]} autoHighlight>
<ComboboxInput aria-label={"ค้นหาไกด์หรือรอบ Stygian"} placeholder={"ทุกไกด์และทุกรอบ"} showClear />
<ComboboxContent><ComboboxEmpty>{"ไม่พบไกด์ที่ตรงกัน"}</ComboboxEmpty><ComboboxList>{(item: TargetOption) => <ComboboxItem key={item.value} value={item}><Avatar className={cn("size-9 shrink-0", characterRarityGradientClass(item.characterKey, item.rarity))}><AvatarImage src={item.image ?? undefined} alt="" /><AvatarFallback>{item.label.slice(0, 2)}</AvatarFallback></Avatar><span className="min-w-0 flex-1 truncate">{item.label}</span></ComboboxItem>}</ComboboxList></ComboboxContent>
</FuzzyCombobox>
</Field>
<Field><FieldLabel>{"การแสดงผล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[status]} onValueChange={(values) => { if (values[0]) setStatus(values[0]); }} variant="outline" aria-label={"กรองการแสดงผลความคิดเห็น"}>
<Field><FieldLabel>{"การแสดงผล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[status]} onValueChange={(values) => { if (values[0] && values[0] !== status) { setSelectedId(undefined); setStatus(values[0]); } }} variant="outline" aria-label={"กรองการแสดงผลความคิดเห็น"}>
<ToggleGroupItem value="all">{"ทั้งหมด"}</ToggleGroupItem><ToggleGroupItem value="visible">{"แสดงอยู่"}</ToggleGroupItem><ToggleGroupItem value="hidden">{"ซ่อนอยู่"}</ToggleGroupItem>
</ToggleGroup></Field>
<Field><FieldLabel>{"การตอบกลับจากผู้ดูแล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label={"กรองการตอบกลับจากผู้ดูแล"}>
<Field><FieldLabel>{"การตอบกลับจากผู้ดูแล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0] && values[0] !== answered) { setSelectedId(undefined); setAnswered(values[0]); } }} variant="outline" aria-label={"กรองการตอบกลับจากผู้ดูแล"}>
<ToggleGroupItem value="all">{"ทุกความคิดเห็น"}</ToggleGroupItem><ToggleGroupItem value="unanswered">{"ยังไม่ได้ตอบ"}</ToggleGroupItem>
</ToggleGroup></Field>
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />{"รีเฟรช"}</Button>
</FieldGroup>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
{feed.page.items.length > 0 && <section aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"} className="flex min-w-0 flex-col">
{selectedError && <Alert variant="destructive"><AlertDescription>{selectedError}</AlertDescription></Alert>}
{selectedBusy && <AdminCommentRowsSkeleton count={1} />}
{items.length > 0 && <section aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"} className="flex min-w-0 flex-col">
<Table className="table-fixed max-md:block" aria-label={"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian"}>
<TableHeader className="max-md:sr-only"><TableRow>
<TableHead scope="col" className="px-0">{"ความคิดเห็น"}</TableHead>
<TableHead scope="col" className="w-[280px] px-0 text-right">{"เนื้อหา"}</TableHead>
</TableRow></TableHeader>
<TableBody className="max-md:block">
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision}
{items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox defaultExpanded={item.id === selectedId} onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision}
overview={item.target.startsWith("stygian:") ? <Link href={item.href} prefetch={false} className="min-w-0 break-words rounded-lg py-2 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring">
{item.targetName}
</Link> : item.targetImage && <Link href={item.href} prefetch={false} className="shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring">
@@ -70,7 +93,7 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
</TableBody>
</Table>
</section>}
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>{"ไม่พบความคิดเห็นที่ตรงกัน"}</EmptyTitle><EmptyDescription>{"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่"}</EmptyDescription></EmptyHeader></Empty>}
{!items.length && !feed.busy && !feed.error && !selectedBusy && !selectedError && <Empty><EmptyHeader><EmptyTitle>{"ไม่พบความคิดเห็นที่ตรงกัน"}</EmptyTitle><EmptyDescription>{"ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่"}</EmptyDescription></EmptyHeader></Empty>}
{feed.busy && <AdminCommentRowsSkeleton count={feed.page.items.length ? 1 : 3} />}
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>{"ดูความคิดเห็นเพิ่มเติม"}</Button>}
</div>;
@@ -34,6 +34,17 @@ describe("Thai comment UI", () => {
expect(html).not.toContain("No matching comments");
});
it("renders the notification conversation once in the regular inbox table", () => {
const selected = { ...comment, id: "selected", text: "การสนทนาที่เลือก" };
const html = renderToStaticMarkup(<AdminCommentInbox initial={{ items: [comment, selected], nextCursor: null, viewer }}
targets={[]} initialTarget={comment.target} initialComment={selected.id} />);
expect(html).not.toContain("การสนทนาจากการแจ้งเตือน");
expect(html.match(/id="comment-selected"/g)).toHaveLength(1);
expect(html.indexOf('id="comment-selected"')).toBeLessThan(html.indexOf('id="comment-test"'));
expect(html.slice(html.indexOf("<tbody"), html.indexOf("</tbody>"))).toContain('id="comment-selected"');
expect(html).not.toContain("<article");
});
it("renders editing controls in Thai while preserving the draft", () => {
const html = renderToStaticMarkup(<CommentComposer target="guide:test" editing={comment} onSaved={() => {}} />);
expect(html).toContain("บันทึกการแก้ไข");
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
select: vi.fn(), rows: [] as unknown[][], notifications: vi.fn(), send: vi.fn(),
authorize: vi.fn(), publish: vi.fn(), after: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/db", () => ({ getDb: () => ({ select: state.select }) }));
vi.mock("web-push", () => ({ default: { sendNotification: state.send, setVapidDetails: vi.fn() } }));
vi.mock("@/lib/commission/push", () => ({ pushPublicKey: () => "public-key", validPushEndpoint: () => true }));
vi.mock("@/lib/notifications/repository", () => ({ createNotifications: state.notifications }));
vi.mock("./repository", () => ({
authorizeComment: state.authorize, requireCommentViewer: async () => ({ id: "author", admin: false }), commentId: (id: string) => id,
}));
vi.mock("./publish", () => ({ publishComment: state.publish }));
vi.mock("next/server", () => ({ after: state.after }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => {} }));
import { sendCommentPush } from "./push";
import { PATCH } from "@/app/api/comments/[id]/route";
const id = "00000000-0000-4000-8000-000000000001";
const subscription = { endpoint: "https://fcm.googleapis.com/fcm/send/test", p256dh: "key", auth: "auth" };
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("BETTER_AUTH_URL", "https://guide.example.test");
state.rows = [];
state.select.mockImplementation(() => {
const rows = state.rows.shift() ?? [];
const query = { from: () => query, where: () => query, limit: () => Promise.resolve(rows), then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) };
return query;
});
state.authorize.mockResolvedValue({ destination: { target: "guide:test", href: "/test/comment", name: "Test Guide", writable: true } });
state.publish.mockResolvedValue({ id, version: 2 });
state.notifications.mockResolvedValue(undefined);
state.send.mockResolvedValue(undefined);
});
afterEach(() => vi.unstubAllEnvs());
describe("comment notifications", () => {
it.each([2, 3])("ignores an explicit edit revision %s before querying or sending", async (version) => {
await sendCommentPush(id, version);
expect(state.select).not.toHaveBeenCalled();
expect(state.notifications).not.toHaveBeenCalled();
expect(state.send).not.toHaveBeenCalled();
});
it.each([null, "parent-id"])("ignores an edited comment or reply when no revision is supplied (parent %s)", async (replyToId) => {
state.rows = [[{ id, version: 2, hidden: false, deletedAt: null, replyToId }]];
await sendCommentPush(id);
expect(state.select).toHaveBeenCalledTimes(1);
expect(state.authorize).not.toHaveBeenCalled();
expect(state.notifications).not.toHaveBeenCalled();
expect(state.send).not.toHaveBeenCalled();
});
it.each([false, true])("still sends new-comment notifications (reply: %s)", async (reply) => {
const recipient = { id: reply ? "reader" : "admin", admin: !reply };
state.rows = [
[{ id, version: 1, hidden: false, deletedAt: null, authorId: "author", replyToId: reply ? "parent-id" : null, rootId: reply ? "root-id" : null }],
...(reply ? [[{ authorId: recipient.id, hidden: false, deletedAt: null }], [{ id: recipient.id, role: "user", banned: false }]] : [[{ id: recipient.id }]]),
[{ name: "Author" }], [{ text: "Original comment" }], [subscription],
];
await sendCommentPush(id, 1);
expect(state.notifications).toHaveBeenCalledWith([expect.objectContaining({ userId: recipient.id, adminOnly: !reply })], expect.objectContaining({
kind: reply ? "comment_reply" : "comment_new", eventKey: `comment:${id}`, body: "Original comment",
}));
expect(state.send).toHaveBeenCalledTimes(1);
expect(JSON.parse(state.send.mock.calls[0][1])).toMatchObject({ body: "Original comment" });
});
it("still delivers a delayed initial notification when the comment has since been edited", async () => {
state.rows = [
[{ id, version: 2, hidden: false, deletedAt: null, authorId: "author", replyToId: null }],
[{ id: "admin" }], [{ name: "Author" }], [{ text: "Original revision" }], [subscription],
];
await sendCommentPush(id, 1);
expect(state.notifications).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ kind: "comment_new", body: "Original revision" }));
expect(state.send).toHaveBeenCalledTimes(1);
});
it("saves edits through PATCH without scheduling a notification", async () => {
const response = await PATCH(new Request(`https://guide.example.test/api/comments/${id}`, {
method: "PATCH", headers: { Origin: "https://guide.example.test" },
}), { params: Promise.resolve({ id }) });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id, version: 2 });
expect(state.publish).toHaveBeenCalled();
expect(state.after).not.toHaveBeenCalled();
expect(state.notifications).not.toHaveBeenCalled();
expect(state.send).not.toHaveBeenCalled();
});
});
+5 -4
View File
@@ -9,10 +9,11 @@ import { createNotifications } from "@/lib/notifications/repository";
import { authorizeComment } from "./repository";
export async function sendCommentPush(id: string, version?: number) {
if (version !== undefined && version !== 1) return;
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
if (!reply || reply.hidden || reply.deletedAt) return;
const notificationVersion = version ?? reply.version;
const edited = notificationVersion > 1;
if (notificationVersion !== 1) return;
let recipients: { id: string; admin: boolean }[];
let url: string;
let title: string;
@@ -27,7 +28,7 @@ export async function sendCommentPush(id: string, version?: number) {
} catch { return; }
recipients = [{ id: recipient.id, admin: false }];
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
title = edited ? "แก้ไขคำตอบกลับความคิดเห็นของคุณ" : "ตอบกลับความคิดเห็นของคุณ";
title = "ตอบกลับความคิดเห็นของคุณ";
} else {
recipients = (await getDb().select({ id: users.id }).from(users)
.where(and(eq(users.role, "admin"), eq(users.emailVerified, true), ne(users.id, reply.authorId), sql`${users.banned} is not true`)))
@@ -39,7 +40,7 @@ export async function sendCommentPush(id: string, version?: number) {
} catch { return; }
if (!destination.writable) return;
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
title = edited ? `แก้ไขความคิดเห็นใน ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
}
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
@@ -48,7 +49,7 @@ export async function sendCommentPush(id: string, version?: number) {
const notificationTitle = `${author?.name ?? "ผู้ใช้"} ${title}`;
const body = revision?.text.slice(0, 300) || "ส่งรูปภาพ";
await createNotifications(recipients.map(recipient => ({ userId: recipient.id, adminOnly: recipient.admin, url })), {
eventKey: edited ? `comment:${id}:edit:${notificationVersion}` : `comment:${id}`, kind: edited ? "comment_edit" : reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
eventKey: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
});
if (!pushPublicKey()) return;
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(inArray(commentPushSubscriptions.userId, recipients.map(recipient => recipient.id)));
+14 -16
View File
@@ -570,7 +570,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id);
expect((await listNotifications(adminUser)).items).toHaveLength(0);
}, 60000);
it("notifies admins for each saved edit without duplicating the same revision", async () => {
it("saves comment edits without notifying admins", async () => {
const root = await post("Original");
const adminUser = { ...admin, role: "admin", emailVerified: true };
await sendCommentPush(root.id, 1);
@@ -579,22 +579,19 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
const response = await itemRoute.PATCH(request("Edited with image", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ id: root.id, version: 2 });
expect(after).toHaveBeenCalledTimes(1);
const task = vi.mocked(after).mock.calls[0][0];
if (typeof task !== "function") throw new Error("Expected scheduled notification callback");
await task();
await task();
expect(after).not.toHaveBeenCalled();
await sendCommentPush(root.id, 2);
const feed = await listNotifications(adminUser);
expect(feed.unread).toBe(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", eventKey: `comment:${root.id}:edit:2`, body: "Edited with image", readAt: null });
expect(feed.unread).toBe(0);
expect(feed.items).toHaveLength(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_new", eventKey: `comment:${root.id}`, body: "Original" });
await publishComment(request("Third revision", { version: 2 }), author, { id: root.id });
await sendCommentPush(root.id, 3);
expect((await listNotifications(adminUser)).unread).toBe(2);
// A delayed notification must use the revision that triggered it.
expect((await listNotifications(adminUser)).unread).toBe(0);
await sendCommentPush(root.id, 2);
expect((await listNotifications(adminUser)).unread).toBe(2);
expect((await listNotifications(adminUser)).items).toHaveLength(1);
});
it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => {
it("does not notify the direct recipient of edited replies", async () => {
await saveSubscription(author.id);
const root = await post("Root");
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
@@ -603,11 +600,12 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
pushTransport.sendNotification.mockClear();
await publishComment(request("Edited reply", { version: 1, images: [png] }), other, { id: reply.id });
await sendCommentPush(reply.id, 2);
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Edited reply", title: "Reader แก้ไขคำตอบกลับความคิดเห็นของคุณ" });
await sendCommentPush(reply.id);
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
const feed = await listNotifications({ ...author, role: "user", emailVerified: true });
expect(feed.unread).toBe(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", body: "Edited reply" });
expect(feed.unread).toBe(0);
expect(feed.items).toHaveLength(1);
expect(feed.items[0]).toMatchObject({ kind: "comment_reply", body: "Reply" });
expect(feed.items[0].url).toContain(`reply=${reply.id}#comment-${root.id}`);
await publishComment(request("Hidden revision", { version: 2 }), other, { id: reply.id });
await mutateComment(root.id, admin, "moderation", true);