feat(notifications) : add profile pictures and guide previews

This commit is contained in:
2026-10-12 23:58:58 +07:00 Unverified
parent 05350e7aab
commit 28517d9b29
10 changed files with 178 additions and 16 deletions
+15 -3
View File
@@ -55,19 +55,20 @@ describe("comment notifications", () => {
expect(state.send).not.toHaveBeenCalled();
});
it.each([false, true])("still sends new-comment notifications (reply: %s)", async (reply) => {
it.each([false, true])("still sends new-comment notifications with the author's profile picture (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],
[{ name: "Author", image: "https://profile.example/author.png" }], [{ 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" });
expect(JSON.parse(state.send.mock.calls[0][1])).toMatchObject({ body: "Original comment", icon: "https://profile.example/author.png" });
expect(state.notifications.mock.calls[0][1].title).toBe(reply ? "Author ตอบกลับความคิดเห็นของคุณ" : "Author แสดงความคิดเห็นใหม่ในไกด์ Test Guide");
});
it("still delivers a delayed initial notification when the comment has since been edited", async () => {
@@ -78,6 +79,17 @@ describe("comment notifications", () => {
await sendCommentPush(id, 1);
expect(state.notifications).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ kind: "comment_new", body: "Original revision" }));
expect(state.send).toHaveBeenCalledTimes(1);
expect(JSON.parse(state.send.mock.calls[0][1]).icon).toBe("/icon/nav/Comment.webp");
});
it("keeps Stygian notification titles separate from guide titles", async () => {
state.authorize.mockResolvedValue({ destination: { target: "stygian:123", href: "/stygian/comment", name: "Test round", writable: true } });
state.rows = [
[{ id, version: 1, hidden: false, deletedAt: null, authorId: "author", replyToId: null }],
[{ id: "admin" }], [{ name: "Author" }], [{ text: "Original comment" }], [subscription],
];
await sendCommentPush(id, 1);
expect(state.notifications.mock.calls[0][1].title).toBe("Author แสดงความคิดเห็นใหม่ใน Test round");
});
it("saves edits through PATCH without scheduling a notification", async () => {
+3 -3
View File
@@ -40,9 +40,9 @@ export async function sendCommentPush(id: string, version?: number) {
} catch { return; }
if (!destination.writable) return;
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
title = destination.target.startsWith("guide:") ? `แสดงความคิดเห็นใหม่ในไกด์ ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
}
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
const [author] = await getDb().select({ name: users.name, image: users.image }).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, notificationVersion))).limit(1);
if (!revision) return;
@@ -56,7 +56,7 @@ export async function sendCommentPush(id: string, version?: number) {
if (!subscriptions.length) return;
webPush.setVapidDetails(process.env.WEB_PUSH_SUBJECT!, process.env.WEB_PUSH_PUBLIC_KEY!, process.env.WEB_PUSH_PRIVATE_KEY!);
const payload = JSON.stringify({ id: `comment-${id}`, title: notificationTitle,
body, icon: "/icon/nav/Comment.webp",
body, icon: author?.image || "/icon/nav/Comment.webp",
url });
for (let offset = 0; offset < subscriptions.length; offset += 10) {
await Promise.all(subscriptions.slice(offset, offset + 10).map(async (subscription) => {
+1 -1
View File
@@ -35,7 +35,7 @@ describe("guide publication notifications", () => {
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 ได้แล้ว" },
title: "มีไกด์ใหม่แล้ว!", body: "คุณสามารถดูไกด์ Amber ได้แล้ว!" },
]);
const query = new PgDialect().sqlToQuery(mocks.filters[2] as Parameters<PgDialect["sqlToQuery"]>[0]);
expect(query.sql).toContain('"banned" is not true');
+2 -2
View File
@@ -28,8 +28,8 @@ export async function sendGuidePublicationNotifications(event: OutboxEventLike)
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} ได้แล้ว`,
title: "มีไกด์ใหม่แล้ว!",
body: `คุณสามารถดูไกด์ ${guide.name} ได้แล้ว!`,
});
cursor = recipients[recipients.length - 1].id;
}
+64
View File
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PgDialect } from 'drizzle-orm/pg-core';
import type { SQL } from 'drizzle-orm';
const state = vi.hoisted(() => ({ rows: [] as unknown[][], filters: [] as unknown[], select: vi.fn() }));
vi.mock('server-only', () => ({}));
vi.mock('./events', () => ({ notifyNotificationChange: vi.fn() }));
vi.mock('@/lib/media/storage', () => ({ publicMediaUrl: (key: string) => `https://cdn.example/${key}` }));
vi.mock('@/db', () => ({ getDb: () => ({ select: state.select }) }));
import { listNotifications } from './repository';
const user = { id: 'reader', role: 'user', emailVerified: true };
const guideId = '00000000-0000-4000-8000-000000000001';
const otherGuideId = '00000000-0000-4000-8000-000000000002';
function row(kind: string, eventKey: string) {
return { notification: { id: eventKey, kind, eventKey }, cursorTime: '2026-10-12T00:00:00Z', authorName: null, authorImage: null };
}
beforeEach(() => {
state.rows = [];
state.filters = [];
state.select.mockReset().mockImplementation(() => {
const rows = state.rows.shift() ?? [];
const query = {
from: () => query, leftJoin: () => query, innerJoin: () => query,
where: (filter: unknown) => { state.filters.push(filter); return query; }, orderBy: () => query, limit: () => query,
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
return query;
});
});
describe('guide overview images in notification feeds', () => {
it('loads multiple guide covers in one bounded query while preserving comment authors and unread counts', async () => {
state.rows = [[row('guide_published', `guide:${guideId}:published`), row('guide_published', `guide:${otherGuideId}:published`),
{ ...row('comment_new', 'comment:1'), authorName: 'Reader', authorImage: 'https://profile.example/reader.png' }],
[{ count: 3 }], [{ id: guideId, objectKey: 'guide/cover.png' }, { id: otherGuideId, objectKey: 'guide/other.png' }]];
const result = await listNotifications(user);
expect(result.unread).toBe(3);
expect(result.nextCursor).toBeNull();
expect(result.items.map(item => item.imageUrl)).toEqual(['https://cdn.example/guide/cover.png', 'https://cdn.example/guide/other.png', null]);
expect(result.items[2]).toMatchObject({ authorName: 'Reader', authorImage: 'https://profile.example/reader.png' });
expect(state.select).toHaveBeenCalledTimes(3);
const filter = new PgDialect().sqlToQuery(state.filters[2] as SQL);
expect(filter.params).toContain(guideId);
expect(filter.params).toContain(otherGuideId);
expect(filter.params).toContain(true);
expect(filter.sql).toContain('"is_public"');
expect(filter.sql).toContain('"trashed_at" is null');
});
it('avoids cover queries for other notification kinds or malformed publication keys', async () => {
state.rows = [[row('commission', `guide:${guideId}:published`), row('guide_published', 'guide:invalid:published')], [{ count: 2 }]];
const result = await listNotifications(user);
expect(result.items.map(item => item.imageUrl)).toEqual([null, null]);
expect(state.select).toHaveBeenCalledTimes(2);
});
it('keeps the notification usable without an image when its guide is unavailable', async () => {
state.rows = [[row('guide_published', `guide:${guideId}:published`)], [{ count: 1 }], []];
expect((await listNotifications(user)).items[0].imageUrl).toBeNull();
});
});
+18 -4
View File
@@ -1,8 +1,9 @@
import 'server-only';
import { and, desc, eq, isNull, or, sql } from 'drizzle-orm';
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { getDb } from '@/db';
import { notifications, comments, commentThreads, guides } from '@/db/schema';
import { notifications, comments, commentThreads, guides, users, media } from '@/db/schema';
import { publicMediaUrl } from '@/lib/media/storage';
import { notifyNotificationChange } from './events';
type Recipient = { userId: string; adminOnly?: boolean; url: string };
@@ -21,8 +22,9 @@ export async function listNotifications(user: { id: string; role: string | null;
eq(comments.hidden, false), sql`coalesce(${root.hidden}, false) = false`,
or(isNull(commentThreads.guideId), and(eq(guides.isPublic, true), isNull(guides.trashedAt))),
))));
const query = () => getDb().select({ notification: notifications, cursorTime: sql<string>`to_char(${notifications.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` }).from(notifications)
const query = () => getDb().select({ notification: notifications, authorName: users.name, authorImage: users.image, cursorTime: sql<string>`to_char(${notifications.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` }).from(notifications)
.leftJoin(comments, eq(comments.id, notifications.commentId)).leftJoin(root, eq(root.id, comments.rootId))
.leftJoin(users, and(eq(users.id, comments.authorId), inArray(notifications.kind, ['comment_new', 'comment_reply', 'comment_edit'])))
.leftJoin(commentThreads, eq(commentThreads.id, comments.threadId)).leftJoin(guides, eq(guides.id, commentThreads.guideId));
const [rows, count] = await Promise.all([
query().where(and(visible, before ? sql`(${notifications.createdAt}, ${notifications.id}) < (${before.time}::timestamptz, ${before.id}::uuid)` : undefined)).orderBy(desc(notifications.createdAt), desc(notifications.id)).limit(21),
@@ -31,7 +33,19 @@ export async function listNotifications(user: { id: string; role: string | null;
.leftJoin(commentThreads, eq(commentThreads.id, comments.threadId)).leftJoin(guides, eq(guides.id, commentThreads.guideId))
.where(and(visible, isNull(notifications.readAt))),
]);
const items = rows.slice(0, 20).map(row => row.notification);
const page = rows.slice(0, 20);
const guideIds = [...new Set(page.flatMap(({ notification }) => {
if (notification.kind !== 'guide_published') return [];
const match = /^guide:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):published$/i.exec(notification.eventKey);
return match ? [match[1]] : [];
}))];
const covers = guideIds.length ? await getDb().select({ id: guides.id, objectKey: media.objectKey }).from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId))
.where(and(inArray(guides.id, guideIds), eq(guides.isPublic, true), isNull(guides.trashedAt))) : [];
const guideImages = new Map(covers.map(cover => [`guide:${cover.id}:published`, publicMediaUrl(cover.objectKey)]));
const items = page.map(row => ({ ...row.notification, authorName: row.authorName, authorImage: row.authorImage,
imageUrl: row.notification.kind === 'guide_published' ? guideImages.get(row.notification.eventKey) ?? null : null,
}));
const last = rows[19];
return { items, unread: count[0]?.count ?? 0, nextCursor: rows.length > 20 ? `${last.cursorTime}|${last.notification.id}` : null };
}