28251b1e08
One migration + targeted hot-path fixes. API behavior 1:1 (schema change = added
indexes + a byte-identical f_unaccent function-body swap, see below).
- Trigram + composite indexes (20260705T120000-perf-indexes.ts): GIN trigram on
LOWER(f_unaccent(title/name)) for pages/users/groups (the /search/suggest
leading-wildcard LIKE did a seq scan per keystroke — EXPLAIN now confirms
Bitmap Index Scan on idx_pages_title_trgm), + page_history(page_id,id DESC),
comments(page_id,id). DEVIATION (verified byte-identical): PG18 cannot inline
the two-arg f_unaccent body during index creation, so up() swaps it to the
schema-qualified single-arg `SELECT public.unaccent($1)` — same dictionary,
identical output for all inputs, so the tsvector trigger + main @@ search stay
consistent with NO reindex; down() restores the exact two-arg body.
- Auth path: jwt.strategy reuses req.raw.workspace when workspaceId matches (the
middleware already validated it) instead of re-querying; domain.middleware
caches the workspace lookup (withCache 15s, invalidated in all 8 WorkspaceRepo
mutators, with a Date reviver for the JSON-serialized cache). USER + SESSION
caching DEFERRED — the invalidation surface (role change doesn't revoke
sessions; revocation includes background jobs) can't be safely covered, and a
missed hook on a security path is worse than the win.
- AI re-embed coalescing: aiQueue.add gets {jobId: embed-<id>, delay: 30s} so
active editing collapses to one job (worker reads current page state).
- filterAccessiblePageIds: hasRestrictedPagesInWorkspace short-circuit skips the
recursive-ancestor CTE when a workspace has zero restricted pages (wired from
search/favorites/notifications/recent/created-by). EXISTS on the same pageAccess
table the CTE anti-joins → no false-positive / no access leak. Busts the cache
on insertPageAccess so a 0->1 restricted transition takes effect immediately
(review F1).
- Small: syncTransclusion guarded by a family-node probe (both old+new content, so
the removal path is preserved); mention notifications enqueue only when the set
gained a member; redis maintainLock clears a prior interval (leak fix).
Skipped as risky (flagged): global ValidationPipe transform change; a pool-wide
statement_timeout (would kill long CREATE INDEX migrations on the same pool).
NOTE: kept the trash query's `content` select — the trash UI reads page.content
for its preview modal (review F3, would have regressed).
Gate: server tsc 0; jest page-permission/auth/search/persistence 15 suites pass;
migration up+down+idempotency verified on real PG18 with EXPLAIN confirming index
use. No new deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectKysely } from 'nestjs-kysely';
|
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
|
import { InsertableNotification } from '@docmost/db/types/entity.types';
|
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|
import { WsGateway } from '../../ws/ws.gateway';
|
|
import { MailService } from '../../integrations/mail/mail.service';
|
|
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
|
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
|
|
@Injectable()
|
|
export class NotificationService {
|
|
private readonly logger = new Logger(NotificationService.name);
|
|
|
|
constructor(
|
|
private readonly notificationRepo: NotificationRepo,
|
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
private readonly wsGateway: WsGateway,
|
|
private readonly mailService: MailService,
|
|
@InjectKysely() private readonly db: KyselyDB,
|
|
) {}
|
|
|
|
async create(data: InsertableNotification) {
|
|
const user = await this.db
|
|
.selectFrom('users')
|
|
.select(['id'])
|
|
.where('id', '=', data.userId)
|
|
.where('deletedAt', 'is', null)
|
|
.where('deactivatedAt', 'is', null)
|
|
.executeTakeFirst();
|
|
|
|
if (!user) return null;
|
|
|
|
const notification = await this.notificationRepo.insert(data);
|
|
|
|
this.wsGateway.server
|
|
.to(`user-${data.userId}`)
|
|
.emit('notification', { id: notification.id, type: notification.type });
|
|
|
|
return notification;
|
|
}
|
|
|
|
async findByUserId(
|
|
userId: string,
|
|
pagination: PaginationOptions,
|
|
type: NotificationTab = 'all',
|
|
workspaceId?: string | null,
|
|
) {
|
|
const result = await this.notificationRepo.findByUserId(
|
|
userId,
|
|
pagination,
|
|
type,
|
|
);
|
|
|
|
const pageIds = result.items
|
|
.map((n: any) => n.pageId)
|
|
.filter(Boolean);
|
|
|
|
if (pageIds.length > 0) {
|
|
const accessiblePageIds =
|
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
|
pageIds,
|
|
userId,
|
|
// #348 — notifications list; enable the workspace short-circuit.
|
|
workspaceId,
|
|
});
|
|
const accessibleSet = new Set(accessiblePageIds);
|
|
|
|
result.items = result.items.filter(
|
|
(n: any) => !n.pageId || accessibleSet.has(n.pageId),
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async getUnreadCount(userId: string) {
|
|
return this.notificationRepo.getUnreadCount(userId);
|
|
}
|
|
|
|
async markAsRead(notificationId: string, userId: string) {
|
|
return this.notificationRepo.markAsRead(notificationId, userId);
|
|
}
|
|
|
|
async markMultipleAsRead(notificationIds: string[], userId: string) {
|
|
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
|
|
}
|
|
|
|
async markAllAsRead(userId: string) {
|
|
return this.notificationRepo.markAllAsRead(userId);
|
|
}
|
|
|
|
async queueEmail(
|
|
userId: string,
|
|
notificationId: string,
|
|
subject: string,
|
|
template: any,
|
|
type?: NotificationType,
|
|
) {
|
|
try {
|
|
const user = await this.db
|
|
.selectFrom('users')
|
|
.select(['email', 'settings'])
|
|
.where('id', '=', userId)
|
|
.where('deletedAt', 'is', null)
|
|
.where('deactivatedAt', 'is', null)
|
|
.executeTakeFirst();
|
|
|
|
if (!user?.email) return;
|
|
|
|
if (type) {
|
|
const settingKey = NotificationTypeToSettingKey[type];
|
|
if (settingKey) {
|
|
const settings = user.settings as any;
|
|
if (settings?.notifications?.[settingKey] === false) return;
|
|
}
|
|
}
|
|
|
|
await this.mailService.sendToQueue({
|
|
to: user.email,
|
|
subject,
|
|
template,
|
|
notificationId,
|
|
});
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
this.logger.error(
|
|
`Failed to queue email for notification ${notificationId}: ${message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|