feat(notifications) : add profile pictures and guide previews
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user