- {feed.page.viewer &&
}
{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}{feed.page.viewer ?
void feed.refresh()} />
: เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ
}
ความคิดเห็น
diff --git a/components/comments/comment-card.tsx b/components/comments/comment-card.tsx
index 601c5f7..7ec5e31 100644
--- a/components/comments/comment-card.tsx
+++ b/components/comments/comment-card.tsx
@@ -117,8 +117,8 @@ function RevisionHistory({ id }: { id: string }) {
;
}
-export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
- item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise
; revision?: number; inbox?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
+export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
+ item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
}) {
const router = useRouter();
const [mode, setMode] = useState<"reply" | "edit" | null>(null);
@@ -212,8 +212,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{item.authorName}{item.authorAdmin && ผู้ดูแล}
{inbox && item.rootId && ตอบกลับ}
- {inbox && item.hidden && {item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}}
- {inbox && item.deleted && ลบแล้ว}
+ {(inbox || moderationDetails) && item.hidden && {item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}}
+ {(inbox || moderationDetails) && item.deleted && ลบแล้ว}
{item.version > 1 && canReadHistory && }
{(item.canEdit || viewer?.admin) &&
@@ -236,14 +236,14 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{viewer?.admin ?
: optimistic.hearted && !item.deleted && !item.hidden && ผู้ดูแลถูกใจ}
- {inbox && item.hasReplies && }
+ {inbox && !replies && item.hasReplies && }
{mode && setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
{replies && !item.rootId && (item.replyCount > 0 || expanded) && expandReplies(value.includes("replies"))}>
{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}
{hasOpenedReplies &&
-
+
@@ -271,7 +271,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
;
}
-function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise
}) {
+function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise; moderationDetails?: boolean }) {
const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`);
const { refresh, busy, error, page, more } = feed;
const previousRevision = useRef(revision);
@@ -292,14 +292,14 @@ function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusRep
}, [focusReplyId, busy, error, page, more]);
return
{feed.error &&
{feed.error}}
-
{ await refresh({ silent: true }); await onChange(); }} />
+ { await refresh({ silent: true }); await onChange(); }} />
{feed.busy && }
{feed.page.nextCursor && }
{feed.error && }
;
}
-function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise }) {
+function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise; moderationDetails?: boolean }) {
const loaded = new Set(items.map((item) => item.id));
const groups = new Map();
for (const item of items) {
@@ -314,7 +314,7 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusRepl
parent !== null && index < siblings.length - 1 && "after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
)}>
-
+
{groups.has(reply.id) &&
{branch(reply.id)}
diff --git a/components/comments/comment-push-prompt.tsx b/components/comments/comment-push-prompt.tsx
deleted file mode 100644
index ecb3da6..0000000
--- a/components/comments/comment-push-prompt.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-'use client';
-import { BellIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
- return
;
-}
diff --git a/components/notifications/notification-bell.tsx b/components/notifications/notification-bell.tsx
index 2e8a02c..3b67106 100644
--- a/components/notifications/notification-bell.tsx
+++ b/components/notifications/notification-bell.tsx
@@ -63,7 +63,7 @@ export function NotificationBell() {
const [open, setOpen] = useState(false);
return
{ setOpen(value); if (value) void state.refresh(); }}>
}>
- {state.unread > 0 && {state.unread > 9 ? '9+' : state.unread}}
+ {state.unread > 0 && {state.unread > 9 ? '9+' : state.unread}}
การแจ้งเตือน
diff --git a/lib/comments/publish.ts b/lib/comments/publish.ts
index 5134693..de04602 100644
--- a/lib/comments/publish.ts
+++ b/lib/comments/publish.ts
@@ -16,6 +16,24 @@ type ParsedForm = ReturnType;
type Upload = { id: string; objectKey: string; mimeType: string; byteSize: number };
type Writer = Parameters["transaction"]>[0]>[0];
+async function uploadCommentImage(storage: Awaited>, image: Upload, bytes: Uint8Array) {
+ for (let attempt = 0; ; attempt++) {
+ try {
+ // Retry the same object key, never the database mutation.
+ await storage.write(image.objectKey, bytes, { type: image.mimeType, acl: "public-read", retry: 0 });
+ return;
+ } catch (cause) {
+ const error = cause as { code?: string; name?: string; statusCode?: number } | null;
+ const transient = error && (
+ ["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "ConnectionClosed", "SocketClosed", "InternalError", "SlowDown", "ServiceUnavailable", "RequestTimeout"].includes(error.code ?? "") ||
+ error.name === "TimeoutError" || error.statusCode === 429 || (error.statusCode !== undefined && error.statusCode >= 500)
+ );
+ if (!transient || attempt === 2) throw new HttpError(503, "comment-image-upload-failed");
+ await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
+ }
+ }
+}
+
async function saveRevision(tx: Pick, id: string, version: number, form: ParsedForm, uploaded: Upload[]) {
if (form.keep.length) {
const own = await tx.select({ id: commentAttachments.id }).from(commentAttachments)
@@ -45,18 +63,19 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
const image = { id: crypto.randomUUID(), objectKey: `comments/${crypto.randomUUID()}`, mimeType: file.type, byteSize: file.size };
uploaded.push(image);
// Browser images load directly from the configured S3 public CDN.
- await storage!.write(image.objectKey, bytes, { type: file.type, acl: "public-read" });
+ await uploadCommentImage(storage!, image, bytes);
}
if (options.id) {
- await withCommentLock(options.id, viewer, async (tx, context) => {
+ const version = await withCommentLock(options.id, viewer, async (tx, context) => {
const c = context.comment;
if (c.authorId !== viewer.id) throw new HttpError(403, "not-comment-author");
if (c.deletedAt || c.hidden || context.rootHidden) throw new HttpError(409, "comment-unavailable");
if (c.version !== form.version) throw new HttpError(409, "comment-edited-reload");
await saveRevision(tx, c.id, c.version + 1, form, uploaded);
await tx.update(comments).set({ version: c.version + 1 }).where(eq(comments.id, c.id));
+ return c.version + 1;
});
- return { id: options.id };
+ return { id: options.id, version };
}
const thread = await ensureCommentThread(options.target!, viewer);
return await getDb().transaction(async (tx) => {
@@ -72,7 +91,7 @@ export async function publishComment(request: Request, viewer: CommentViewer, op
}
const [comment] = await tx.insert(comments).values({ threadId: thread.id, authorId: viewer.id, rootId, replyToId: form.replyToId }).returning({ id: comments.id });
await saveRevision(tx, comment.id, 1, form, uploaded);
- return { id: comment.id };
+ return { id: comment.id, version: 1 };
});
} catch (cause) {
if (uploaded.length) {
diff --git a/lib/comments/push.ts b/lib/comments/push.ts
index 3b0a704..009d2fd 100644
--- a/lib/comments/push.ts
+++ b/lib/comments/push.ts
@@ -8,9 +8,11 @@ import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { createNotifications } from "@/lib/notifications/repository";
import { authorizeComment } from "./repository";
-export async function sendCommentPush(id: string) {
+export async function sendCommentPush(id: string, version?: number) {
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;
let recipients: { id: string; admin: boolean }[];
let url: string;
let title: string;
@@ -25,7 +27,7 @@ export async function sendCommentPush(id: string) {
} catch { return; }
recipients = [{ id: recipient.id, admin: false }];
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
- title = "ตอบกลับความคิดเห็นของคุณ";
+ title = edited ? "แก้ไขคำตอบกลับความคิดเห็นของคุณ" : "ตอบกลับความคิดเห็นของคุณ";
} 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`)))
@@ -37,15 +39,16 @@ export async function sendCommentPush(id: string) {
} catch { return; }
if (!destination.writable) return;
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
- title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
+ title = edited ? `แก้ไขความคิดเห็นใน ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${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)
- .where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, reply.version))).limit(1);
+ .where(and(eq(commentRevisions.commentId, id), eq(commentRevisions.version, notificationVersion))).limit(1);
+ if (!revision) return;
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: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
+ eventKey: edited ? `comment:${id}:edit:${notificationVersion}` : `comment:${id}`, kind: edited ? "comment_edit" : 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)));
diff --git a/lib/comments/repository.ts b/lib/comments/repository.ts
index cc66e7d..3cf7cb7 100644
--- a/lib/comments/repository.ts
+++ b/lib/comments/repository.ts
@@ -98,10 +98,11 @@ async function revisionImages(ids: string[], db: Reader = getDb()) {
export async function listComments(options: {
viewer: CommentViewer | null; target?: string; rootId?: string; id?: string; cursor?: string | null;
- sort?: string; inbox?: boolean; status?: string; unanswered?: boolean;
+ sort?: string; inbox?: boolean; grouped?: boolean; status?: string; unanswered?: boolean;
}): Promise {
const { viewer } = options;
if (options.inbox) requireCommentAdmin(viewer);
+ const grouped = Boolean(options.inbox && options.grouped && !options.id && !options.rootId);
const conditions: SQL[] = [];
if (options.id) conditions.push(eq(comments.id, commentId(options.id)));
if (options.target) {
@@ -114,14 +115,21 @@ export async function listComments(options: {
if (context.comment.rootId) throw new HttpError(400, "root-required");
if (options.target && context.destination.target !== options.target) throw new HttpError(404, "comment-not-found");
conditions.push(eq(comments.rootId, options.rootId));
- } else if (!options.inbox && !options.id) conditions.push(isNull(comments.rootId));
+ } else if (grouped || (!options.inbox && !options.id)) conditions.push(isNull(comments.rootId));
if (!viewer?.admin) {
conditions.push(sql`coalesce(${root.hidden}, false) = false`, publicTarget);
if (!options.rootId) conditions.push(eq(comments.hidden, false));
if (!options.rootId && !options.id) conditions.push(or(isNull(comments.deletedAt), sql`${replyCount} > 0`)!);
}
- if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
- if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
+ if (grouped) {
+ if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), sql`exists (
+ select 1 from ${comments} reply where reply.root_id = ${comments.id} and reply.hidden)`)!);
+ if (options.status === "visible") conditions.push(eq(comments.hidden, false), or(isNull(comments.deletedAt), sql`exists (
+ select 1 from ${comments} reply where reply.root_id = ${comments.id} and not reply.hidden and reply.deleted_at is null)`)!);
+ } else {
+ if (options.status === "hidden") conditions.push(or(eq(comments.hidden, true), eq(root.hidden, true))!);
+ if (options.status === "visible") conditions.push(eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`, isNull(comments.deletedAt));
+ }
if (options.unanswered) conditions.push(isNull(comments.rootId), isNull(comments.deletedAt), sql`not exists (
select 1 from ${comments} reply join ${users} author on author.id = reply.author_id
where reply.root_id = ${comments.id} and author.role = 'admin' and author.email_verified and not reply.hidden and reply.deleted_at is null)`);
diff --git a/tests/comments.integration.test.ts b/tests/comments.integration.test.ts
index 8e7885a..3bdab85 100644
--- a/tests/comments.integration.test.ts
+++ b/tests/comments.integration.test.ts
@@ -2,6 +2,7 @@ import { randomInt, randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import sharp from "sharp";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { after } from "next/server";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
@@ -84,6 +85,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
storage.write.mockReset().mockResolvedValue(undefined); storage.delete.mockReset().mockResolvedValue(undefined);
session.get.mockResolvedValue(null);
rateLimit.mockReset().mockResolvedValue(undefined);
+ vi.mocked(after).mockClear();
});
afterAll(async () => {
const db = getDb();
@@ -127,6 +129,39 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
expect((await page()).items[0].replyCount).toBe(2);
await expect(publishComment(request("Bad reply", { reply: reply.id }), author, { target: `guide:${otherGuideId}` })).rejects.toMatchObject({ status: 400 });
});
+ it("groups the admin inbox and keeps nested admin replies attached to their recipient", async () => {
+ const root = await post();
+ const reply = await post("Reader reply", { reply: root.id });
+ const nested = await publishComment(request("Admin reply", { reply: reply.id }), admin, { target });
+ const second = await post("Second conversation");
+ const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
+ expect(grouped.items.map((item) => item.id)).toEqual([second.id, root.id]);
+ expect(grouped.items[1].replyCount).toBe(2);
+ const replies = await listComments({ viewer: admin, rootId: root.id });
+ expect(replies.items[1]).toMatchObject({ id: nested.id, rootId: root.id, replyToId: reply.id, authorAdmin: true });
+ expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, unanswered: true })).items.map((item) => item.id)).toEqual([second.id]);
+ expect((await listComments({ viewer: admin, inbox: true, target })).items).toHaveLength(4);
+ expect((await listComments({ viewer: admin, inbox: true, grouped: true, target: `guide:${otherGuideId}` })).items).toHaveLength(0);
+ session.get.mockResolvedValue({ user: { id: admin.id } });
+ const response = await inboxRoute.GET(new Request(`https://guide.example.test/api/admin/comments?grouped=true&target=${target}`));
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
+ expect((await response.json()).items.map((item: { id: string }) => item.id)).toEqual([second.id, root.id]);
+ });
+ it("finds grouped conversations by reply visibility without filtering their context", async () => {
+ const root = await post();
+ const reply = await post("Reply", { reply: root.id });
+ const nested = await post("Nested reply", { reply: reply.id });
+ await mutateComment(nested.id, admin, "moderation", true);
+ const hidden = await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" });
+ expect(hidden.items.map((item) => item.id)).toEqual([root.id]);
+ expect(hidden.items[0].hidden).toBe(false);
+ expect((await listComments({ viewer: admin, rootId: root.id })).items).toHaveLength(2);
+ expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items.map((item) => item.id)).toEqual([root.id]);
+ await mutateComment(root.id, admin, "moderation", true);
+ expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items).toHaveLength(0);
+ expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" })).items).toHaveLength(1);
+ });
it("keeps Stygian schedule threads independent", async () => {
await publishComment(request("Schedule A"), author, { target: `stygian:${scheduleId}` });
expect((await listComments({ viewer: author, target: `stygian:${scheduleId + 1}` })).items).toHaveLength(0);
@@ -174,6 +209,47 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
await expect(publishComment(request("Foreign image", { version: 1, keep: [image.id] }), author, { id: otherRoot.id })).rejects.toMatchObject({ status: 400 });
await expect(mutateComment(root.id, other, "delete")).rejects.toMatchObject({ status: 403 });
});
+ it("edits images through PATCH while preserving retained images and revision history", async () => {
+ const root = await post("Original", { images: [png] });
+ const originalImage = (await page()).items[0].images[0];
+ session.get.mockResolvedValue({ user: { id: author.id } });
+ const context = { params: Promise.resolve({ id: root.id }) };
+ const edit = request("Added image", { version: 1, keep: [originalImage.id], images: [png] });
+ const response = await itemRoute.PATCH(new Request(edit, { method: "PATCH" }), context);
+ expect(await response.json()).toMatchObject({ id: root.id });
+ expect(response.status).toBe(200);
+ const updated = (await page()).items[0];
+ expect(updated).toMatchObject({ version: 2, text: "Added image" });
+ expect(updated.images).toHaveLength(2);
+ expect(updated.images[0]).toEqual(originalImage);
+ const replacement = request("", { version: 2, images: [png] });
+ expect((await itemRoute.PATCH(new Request(replacement, { method: "PATCH" }), context)).status).toBe(200);
+ expect((await page()).items[0].images).toHaveLength(1);
+ const history = await commentHistory(root.id, author);
+ expect(history.items.map((item) => item.images.length)).toEqual([1, 2, 1]);
+ });
+ it("retries temporary image-storage failures without duplicating the edit", async () => {
+ const root = await post("Original");
+ storage.write.mockRejectedValueOnce(Object.assign(new Error("Temporary storage failure"), { code: "ECONNRESET" }));
+ await publishComment(request("Edited", { version: 1, images: [png] }), author, { id: root.id });
+ expect(storage.write).toHaveBeenCalledTimes(2);
+ expect(storage.write.mock.calls[0][0]).toBe(storage.write.mock.calls[1][0]);
+ expect((await page()).items[0]).toMatchObject({ text: "Edited", version: 2 });
+ expect((await commentHistory(root.id, author)).items).toHaveLength(2);
+ expect(storage.delete).not.toHaveBeenCalled();
+ });
+ it("keeps the original revision and cleans up images when storage retries fail", async () => {
+ const root = await post("Original");
+ storage.write.mockRejectedValue(Object.assign(new Error("Storage unavailable"), { statusCode: 503 }));
+ session.get.mockResolvedValue({ user: { id: author.id } });
+ const response = await itemRoute.PATCH(request("Failed edit", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
+ expect(response.status).toBe(503);
+ expect(await response.json()).toEqual({ error: "comment-image-upload-failed" });
+ expect(storage.write).toHaveBeenCalledTimes(3);
+ expect(storage.delete).toHaveBeenCalledTimes(1);
+ expect((await page()).items[0]).toMatchObject({ text: "Original", version: 1 });
+ expect(after).not.toHaveBeenCalled();
+ });
it("blocks hidden-root replies, history, and attachments and restores access", async () => {
const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] });
const image = (await page()).items[0].images[0].id;
@@ -255,6 +331,10 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
const replies = await listComments({ viewer: author, rootId: roots[0].id });
expect(replies.items).toHaveLength(20);
expect((await listComments({ viewer: author, rootId: roots[0].id, cursor: replies.nextCursor })).items).toHaveLength(2);
+ const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
+ const groupedNext = await listComments({ viewer: admin, inbox: true, grouped: true, target, cursor: grouped.nextCursor });
+ expect(grouped.items).toHaveLength(20); expect(groupedNext.items).toHaveLength(3);
+ expect(new Set([...grouped.items, ...groupedNext.items].map((item) => item.id))).toEqual(new Set(roots.map((item) => item.id)));
}, 30000);
it("filters unanswered roots and treats an admin reply as an answer", async () => {
const root = await post();
@@ -267,7 +347,7 @@ describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () =>
await expect(post("Fake", { images: [new Uint8Array([0, 1, 2])] })).rejects.toMatchObject({ status: 422 });
expect(storage.write).not.toHaveBeenCalled();
storage.write.mockRejectedValueOnce(new Error("storage unavailable"));
- await expect(post("Failed", { images: [png] })).rejects.toThrow("storage unavailable");
+ await expect(post("Failed", { images: [png] })).rejects.toMatchObject({ status: 503, message: "comment-image-upload-failed" });
expect(storage.delete).toHaveBeenCalledWith(storage.write.mock.calls[0][0]);
expect((await page()).items).toHaveLength(0);
});
@@ -466,6 +546,52 @@ 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 () => {
+ const root = await post("Original");
+ const adminUser = { ...admin, role: "admin", emailVerified: true };
+ await sendCommentPush(root.id, 1);
+ await readNotifications(admin.id);
+ session.get.mockResolvedValue({ user: { id: author.id } });
+ 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();
+ 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 });
+ 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.
+ await sendCommentPush(root.id, 2);
+ expect((await listNotifications(adminUser)).unread).toBe(2);
+ });
+ it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => {
+ await saveSubscription(author.id);
+ const root = await post("Root");
+ const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
+ await sendCommentPush(reply.id, 1);
+ await readNotifications(author.id);
+ 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 แก้ไขคำตอบกลับความคิดเห็นของคุณ" });
+ 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.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);
+ pushTransport.sendNotification.mockClear();
+ await sendCommentPush(reply.id, 3);
+ expect(pushTransport.sendNotification).not.toHaveBeenCalled();
+ expect((await getDb().select().from(schema.notifications).where(eq(schema.notifications.eventKey, `comment:${reply.id}:edit:3`)))).toHaveLength(0);
+ });
it("pages notification history without dropping notifications sharing a timestamp", async () => {
const user = { ...author, role: "user", emailVerified: true };
for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" });