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
+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)));