feat(notifications) : notify guide publications and admin hearts; simplify Stygian navigation
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import Image from "@/components/ui/resilient-image";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { SiteFooter } from "@/components/public/site-footer";
|
||||
@@ -37,12 +36,6 @@ export default async function StygianPage({ searchParams }: { searchParams: Prom
|
||||
{schedules.length ? <div className="flex flex-col gap-2"><p className="text-sm font-medium">เลือกเวอร์ชัน</p><nav className="flex flex-wrap gap-2" aria-label="เวอร์ชัน Stygian">{schedules.map((entry) =>
|
||||
<Link key={entry.scheduleId} href={`/stygian?schedule=${entry.scheduleId}`} aria-current={entry.scheduleId === selectedId ? "page" : undefined}
|
||||
className={buttonVariants({ variant: entry.scheduleId === selectedId ? "default" : "outline", size: "sm", className: "max-sm:min-h-11" })}>{entry.challengeName}</Link>)}</nav></div> : null}
|
||||
{schedule && <nav aria-label="Stygian navigation" className="flex flex-wrap gap-2">
|
||||
<Link href={`/stygian?schedule=${schedule.scheduleId}`} aria-current="page" className={buttonVariants({ variant: "secondary" })}>Guide</Link>
|
||||
<a href="#comments" className={buttonVariants({ variant: "outline" })}>
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" />ความคิดเห็น
|
||||
</a>
|
||||
</nav>}
|
||||
{schedule && catalog ? <StygianGuide schedule={schedule} catalog={catalog} /> : (
|
||||
<Empty className="border"><EmptyHeader><EmptyTitle>ยังไม่มีไกด์ Stygian</EmptyTitle><EmptyDescription>กำลังเตรียมข้อมูล กลับมาอ่านได้ภายหลัง หรือเลือกไกด์ตัวละครระหว่างรอ</EmptyDescription></EmptyHeader><Link href="/" className={buttonVariants({ variant: "outline" })}>เลือกไกด์ตัวละคร</Link></Empty>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown> | null,
|
||||
publicGuide: true, notifications: [] as Record<string, unknown>[],
|
||||
notify: vi.fn(), changed: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: state.notify }));
|
||||
vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed }));
|
||||
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() }));
|
||||
vi.mock("@/lib/audit-log", () => ({ auditActor: vi.fn(), writeAuditLog: vi.fn() }));
|
||||
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() }));
|
||||
vi.mock("@/db", () => ({ getDb: () => database }));
|
||||
|
||||
import { comments, guides, users } from "@/db/schema";
|
||||
import { mutateComment } from "./repository";
|
||||
|
||||
const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f";
|
||||
const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e";
|
||||
const admin = { id: "admin", admin: true };
|
||||
const database = {
|
||||
transaction: async (task: (tx: unknown) => unknown) => task(database),
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
let table: unknown;
|
||||
const rows = () => {
|
||||
if (table === comments) return fields.comment
|
||||
? [{ comment: state.comment, thread: { id: "thread", guideId } }]
|
||||
: [{ hidden: false }];
|
||||
if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: state.publicGuide, trashedAt: null }];
|
||||
if (table === users) return state.author ? [state.author] : [];
|
||||
return [];
|
||||
};
|
||||
const query = {
|
||||
from(value: unknown) { table = value; return query; },
|
||||
innerJoin() { return query; }, where() { return query; },
|
||||
limit: async () => rows(), for: async () => rows(),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
update: () => {
|
||||
const query = { set: () => query, where: async () => undefined };
|
||||
return query;
|
||||
},
|
||||
insert: () => ({ values: (value: Record<string, unknown>) => ({ onConflictDoNothing: () => ({ returning: async () => {
|
||||
if (state.notifications.some(old => old.eventKey === value.eventKey)) return [];
|
||||
state.notifications.push(value);
|
||||
return [{ userId: value.userId }];
|
||||
} }) }) }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.comment = { id, authorId: "reader", rootId: null, hidden: false, deletedAt: null, heartedById: null };
|
||||
state.author = { id: "reader", email: "[email protected]", role: "user", emailVerified: true, banned: false };
|
||||
state.publicGuide = true; state.notifications.length = 0; state.notify.mockReset(); state.changed.mockReset();
|
||||
});
|
||||
|
||||
describe("admin heart notifications", () => {
|
||||
it("notifies the comment author and refreshes their bell after saving", async () => {
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications).toEqual([expect.objectContaining({ userId: "reader", kind: "comment_heart",
|
||||
title: "คุณได้รับหัวใจจากแอดมิน", url: `/amber/comment#comment-${id}`, commentId: id })]);
|
||||
expect(state.notify).toHaveBeenCalledWith("reader");
|
||||
});
|
||||
|
||||
it("links a reply to its exact thread", async () => {
|
||||
state.comment.rootId = "root-id";
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications[0].url).toBe(`/amber/comment?reply=${id}#comment-root-id`);
|
||||
});
|
||||
|
||||
it.each(["removed", "already hearted", "self", "banned", "missing", "private guide"])("skips notifications for %s", async (scenario) => {
|
||||
if (scenario === "already hearted") state.comment.heartedById = "other-admin";
|
||||
if (scenario === "self") state.comment.authorId = admin.id;
|
||||
if (scenario === "banned") state.author!.banned = true;
|
||||
if (scenario === "missing") state.author = null;
|
||||
if (scenario === "private guide") state.publicGuide = false;
|
||||
await mutateComment(id, admin, "heart", scenario !== "removed");
|
||||
expect(state.notifications).toHaveLength(0);
|
||||
expect(state.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not duplicate a notification when the heart is added again", async () => {
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
await mutateComment(id, admin, "heart", false);
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications).toHaveLength(1);
|
||||
expect(state.notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects hearts from regular users", async () => {
|
||||
await expect(mutateComment(id, { id: "reader", admin: false }, "heart", true)).rejects.toMatchObject({ status: 403 });
|
||||
expect(state.notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,24 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
|
||||
} else if (action === "heart") {
|
||||
requireCommentAdmin(viewer);
|
||||
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
|
||||
if (value && !c.heartedById && c.authorId !== viewer.id) {
|
||||
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified, banned: users.banned })
|
||||
.from(users).where(eq(users.id, c.authorId)).limit(1);
|
||||
if (!author || author.banned) return;
|
||||
let destination;
|
||||
try {
|
||||
({ destination } = await authorizeComment(id, { id: author.id, admin: isAuthorizedAdmin(author) }, tx));
|
||||
} catch (cause) {
|
||||
if (cause instanceof HttpError && cause.status === 404) return;
|
||||
throw cause;
|
||||
}
|
||||
const url = `${destination.href}${c.rootId ? `${destination.href.includes("?") ? "&" : "?"}reply=${id}` : ""}#comment-${c.rootId ?? id}`;
|
||||
notificationUsers = await tx.insert(notifications).values({
|
||||
userId: author.id, eventKey: `comment:${id}:heart`, kind: "comment_heart",
|
||||
title: "คุณได้รับหัวใจจากแอดมิน", body: `แอดมินให้หัวใจความคิดเห็นของคุณใน ${destination.name}`,
|
||||
url, commentId: id,
|
||||
}).onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId });
|
||||
}
|
||||
} else if (value === 0) {
|
||||
await tx.delete(commentReactions).where(and(eq(commentReactions.commentId, id), eq(commentReactions.userId, viewer.id)));
|
||||
} else {
|
||||
|
||||
@@ -599,6 +599,11 @@ export async function setGuideVisibility(
|
||||
context: GuideMutationContext,
|
||||
) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const [previous] = await tx
|
||||
.select({ isPublic: guides.isPublic, publishedAt: guides.publishedAt })
|
||||
.from(guides)
|
||||
.where(eq(guides.id, guideId))
|
||||
.for("update");
|
||||
const [guide] = await tx
|
||||
.update(guides)
|
||||
.set({
|
||||
@@ -624,6 +629,14 @@ export async function setGuideVisibility(
|
||||
throw new GuideVersionConflictError(current.version);
|
||||
}
|
||||
await emitGuideEvents(tx, guide.id, guide.version, true);
|
||||
if (input.isPublic && previous && !previous.isPublic && !previous.publishedAt) {
|
||||
await tx.insert(outboxEvents).values({
|
||||
topic: "notifications:guide",
|
||||
aggregateId: guide.id,
|
||||
eventType: "guide.published",
|
||||
payload: { publishedAt: guide.publishedAt!.toISOString() },
|
||||
});
|
||||
}
|
||||
await auditGuide(
|
||||
tx,
|
||||
context,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ rows: [] as unknown[][], filters: [] as unknown[], create: vi.fn() }));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/notifications/repository", () => ({ createNotifications: mocks.create }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ select: () => {
|
||||
const query = {
|
||||
from() { return query; },
|
||||
where(filter: unknown) { mocks.filters.push(filter); return query; },
|
||||
orderBy() { return query; },
|
||||
limit() { return Promise.resolve(mocks.rows.shift() ?? []); },
|
||||
};
|
||||
return query;
|
||||
} }) }));
|
||||
|
||||
import { sendGuidePublicationNotifications } from "./notifications";
|
||||
|
||||
const event = {
|
||||
aggregateId: "72df08ab-50dd-4cbd-9a69-70949d34cf9f",
|
||||
eventType: "guide.published", topic: "notifications:guide",
|
||||
payload: { publishedAt: "2026-10-08T10:00:00.000Z" },
|
||||
};
|
||||
|
||||
beforeEach(() => { mocks.rows.length = 0; mocks.filters.length = 0; mocks.create.mockReset(); });
|
||||
|
||||
describe("guide publication notifications", () => {
|
||||
it("notifies all existing active users in batches with a retry-safe event key and guide link", async () => {
|
||||
mocks.rows.push([{ name: "Amber", slug: "amber" }],
|
||||
Array.from({ length: 100 }, (_, index) => ({ id: `user-${index.toString().padStart(3, "0")}` })),
|
||||
[{ id: "user-100" }], []);
|
||||
await sendGuidePublicationNotifications(event);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.create.mock.calls[0][0]).toHaveLength(100);
|
||||
expect(mocks.create.mock.calls[1]).toEqual([
|
||||
[{ userId: "user-100", url: "/amber" }],
|
||||
{ eventKey: `guide:${event.aggregateId}:published`, kind: "guide_published",
|
||||
title: "เผยแพร่ Guide ใหม่", body: "อ่าน Guide Amber ได้แล้ว" },
|
||||
]);
|
||||
const query = new PgDialect().sqlToQuery(mocks.filters[2] as Parameters<PgDialect["sqlToQuery"]>[0]);
|
||||
expect(query.sql).toContain('"banned" is not true');
|
||||
expect(query.sql).toContain('"created_at" <=');
|
||||
expect(query.sql).toContain('"id" >');
|
||||
expect(query.params).toContain("user-099");
|
||||
});
|
||||
|
||||
it("skips a guide that is private, trashed, or deleted before delivery", async () => {
|
||||
mocks.rows.push([]);
|
||||
await sendGuidePublicationNotifications(event);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates delivery failures so the outbox retries", async () => {
|
||||
mocks.rows.push([{ name: "Amber", slug: "amber" }], [{ id: "user" }]);
|
||||
mocks.create.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
await expect(sendGuidePublicationNotifications(event)).rejects.toThrow("database unavailable");
|
||||
});
|
||||
|
||||
it("rejects malformed events before reading users", async () => {
|
||||
await expect(sendGuidePublicationNotifications({ ...event, payload: {} })).rejects.toThrow("Invalid guide publication");
|
||||
await expect(sendGuidePublicationNotifications({ ...event, topic: "admin" })).rejects.toThrow("Invalid guide publication");
|
||||
expect(mocks.filters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, gt, isNull, lte, sql } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { guides, users } from "@/db/schema";
|
||||
import type { OutboxEventLike } from "@/lib/events/invalidation";
|
||||
import { createNotifications } from "@/lib/notifications/repository";
|
||||
|
||||
export async function sendGuidePublicationNotifications(event: OutboxEventLike) {
|
||||
const publishedAt = typeof event.payload.publishedAt === "string"
|
||||
? new Date(event.payload.publishedAt) : null;
|
||||
if (event.eventType !== "guide.published" || event.topic !== "notifications:guide"
|
||||
|| !publishedAt || Number.isNaN(publishedAt.getTime())) {
|
||||
throw new Error("Invalid guide publication notification event");
|
||||
}
|
||||
const db = getDb();
|
||||
const [guide] = await db.select({ name: guides.name, slug: guides.slug }).from(guides)
|
||||
.where(and(eq(guides.id, event.aggregateId), eq(guides.isPublic, true), isNull(guides.trashedAt))).limit(1);
|
||||
if (!guide) return;
|
||||
|
||||
let cursor: string | undefined;
|
||||
while (true) {
|
||||
const recipients: { id: string }[] = await db.select({ id: users.id }).from(users)
|
||||
.where(and(sql`${users.banned} is not true`, lte(users.createdAt, publishedAt),
|
||||
cursor === undefined ? undefined : gt(users.id, cursor)))
|
||||
.orderBy(asc(users.id)).limit(100);
|
||||
if (!recipients.length) return;
|
||||
await createNotifications(recipients.map(user => ({ userId: user.id, url: `/${guide.slug}` })), {
|
||||
eventKey: `guide:${event.aggregateId}:published`,
|
||||
kind: "guide_published",
|
||||
title: "เผยแพร่ Guide ใหม่",
|
||||
body: `อ่าน Guide ${guide.name} ได้แล้ว`,
|
||||
});
|
||||
cursor = recipients[recipients.length - 1].id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { outboxEvents } from "@/db/schema";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ previous: { isPublic: false, publishedAt: null as Date | null },
|
||||
updated: [] as unknown[], current: [] as unknown[], inserts: [] as { table: unknown; values: unknown }[] }));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/audit-log", () => ({ writeAuditLog: vi.fn() }));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ transaction: async (callback: (tx: unknown) => unknown) => {
|
||||
const tx = {
|
||||
select: () => {
|
||||
const query = { from: () => query, where: () => query,
|
||||
for: async () => [mocks.previous], limit: async () => mocks.current };
|
||||
return query;
|
||||
},
|
||||
update: () => { const query = { set: () => query, where: () => query, returning: async () => mocks.updated }; return query; },
|
||||
insert: (table: unknown) => ({ values: async (values: unknown) => { mocks.inserts.push({ table, values }); } }),
|
||||
};
|
||||
return callback(tx);
|
||||
} }) }));
|
||||
|
||||
import { GuideVersionConflictError, setGuideVisibility } from "./mutations";
|
||||
|
||||
const guide = { id: "guide-id", version: 2, name: "Amber", characterKey: "amber", publishedAt: new Date("2026-10-08T10:00:00Z") };
|
||||
const context = { actor: { id: "admin", label: "Admin" } };
|
||||
beforeEach(() => {
|
||||
mocks.previous = { isPublic: false, publishedAt: null };
|
||||
mocks.updated = [guide]; mocks.current = []; mocks.inserts.length = 0;
|
||||
});
|
||||
const publicationEvents = () => mocks.inserts.filter(row => row.table === outboxEvents && !Array.isArray(row.values));
|
||||
|
||||
describe("guide publication trigger", () => {
|
||||
it("queues delivery in the publishing transaction", async () => {
|
||||
await setGuideVisibility(guide.id, { isPublic: true, expectedVersion: 1 }, context);
|
||||
expect(publicationEvents().map(row => row.values)).toEqual([{
|
||||
topic: "notifications:guide", aggregateId: guide.id, eventType: "guide.published",
|
||||
payload: { publishedAt: guide.publishedAt.toISOString() },
|
||||
}]);
|
||||
});
|
||||
|
||||
it.each(["unpublish", "republish", "already public"])("does not notify on %s", async (scenario) => {
|
||||
if (scenario === "republish") mocks.previous.publishedAt = guide.publishedAt;
|
||||
if (scenario === "already public") mocks.previous.isPublic = true;
|
||||
await setGuideVisibility(guide.id, { isPublic: scenario !== "unpublish", expectedVersion: 1 }, context);
|
||||
expect(publicationEvents()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves version conflicts without queuing notifications", async () => {
|
||||
mocks.updated = []; mocks.current = [{ version: 5 }];
|
||||
await expect(setGuideVisibility(guide.id, { isPublic: true, expectedVersion: 1 }, context)).rejects.toBeInstanceOf(GuideVersionConflictError);
|
||||
expect(mocks.inserts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/outbox/processor";
|
||||
import { closeRedisClient, getRedisClient } from "@/lib/redis/client";
|
||||
import { removeExpiredUnpaidCheckouts } from "@/lib/commission/cleanup";
|
||||
import { sendGuidePublicationNotifications } from "@/lib/guides/notifications";
|
||||
|
||||
let stopping = false;
|
||||
const stop = () => {
|
||||
@@ -58,7 +59,11 @@ async function run(): Promise<void> {
|
||||
await Promise.all(
|
||||
events.map(async (event) => {
|
||||
try {
|
||||
if (event.eventType === "guide.published") {
|
||||
await sendGuidePublicationNotifications(event);
|
||||
} else {
|
||||
await processOutboxEvent(event, transport);
|
||||
}
|
||||
await markOutboxProcessed(event.id);
|
||||
} catch (cause) {
|
||||
await releaseOutboxEvent(event.id, event.attempts, cause);
|
||||
|
||||
Reference in New Issue
Block a user