mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
@@ -0,0 +1,407 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||
import { Config } from '../../base/config';
|
||||
import {
|
||||
Models,
|
||||
NotificationLevel,
|
||||
NotificationType,
|
||||
User,
|
||||
Workspace,
|
||||
} from '../../models';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
|
||||
t.context.models = module.get(Models);
|
||||
t.context.config = module.get(Config);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
let user: User;
|
||||
let createdBy: User;
|
||||
let workspace: Workspace;
|
||||
let docId: string;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
user = await t.context.models.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
createdBy = await t.context.models.user.create({
|
||||
email: 'createdBy@affine.pro',
|
||||
});
|
||||
workspace = await t.context.models.workspace.create(user.id);
|
||||
docId = randomUUID();
|
||||
await t.context.models.doc.upsert({
|
||||
spaceId: user.id,
|
||||
docId,
|
||||
blob: Buffer.from('hello'),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach.always(() => {
|
||||
mock.reset();
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should create a mention notification with default level', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
t.is(notification.level, NotificationLevel.Default);
|
||||
t.is(notification.body.workspaceId, workspace.id);
|
||||
t.is(notification.body.doc.id, docId);
|
||||
t.is(notification.body.doc.title, 'doc-title');
|
||||
t.is(notification.body.doc.blockId, 'blockId');
|
||||
t.is(notification.body.createdByUserId, createdBy.id);
|
||||
t.is(notification.type, NotificationType.Mention);
|
||||
t.is(notification.read, false);
|
||||
});
|
||||
|
||||
test('should create a mention notification with custom level', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
elementId: 'elementId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
level: NotificationLevel.High,
|
||||
});
|
||||
t.is(notification.level, NotificationLevel.High);
|
||||
t.is(notification.body.workspaceId, workspace.id);
|
||||
t.is(notification.body.doc.id, docId);
|
||||
t.is(notification.body.doc.title, 'doc-title');
|
||||
t.is(notification.body.doc.elementId, 'elementId');
|
||||
t.is(notification.body.createdByUserId, createdBy.id);
|
||||
t.is(notification.type, NotificationType.Mention);
|
||||
t.is(notification.read, false);
|
||||
});
|
||||
|
||||
test('should mark a mention notification as read', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
t.is(notification.read, false);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await t.context.models.notification.get(
|
||||
notification.id
|
||||
);
|
||||
t.is(updatedNotification!.read, true);
|
||||
});
|
||||
|
||||
test('should create an invite notification', async t => {
|
||||
const inviteId = randomUUID();
|
||||
const notification = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
t.is(notification.type, NotificationType.Invitation);
|
||||
t.is(notification.body.workspaceId, workspace.id);
|
||||
t.is(notification.body.createdByUserId, createdBy.id);
|
||||
t.is(notification.body.inviteId, inviteId);
|
||||
t.is(notification.read, false);
|
||||
});
|
||||
|
||||
test('should mark an invite notification as read', async t => {
|
||||
const inviteId = randomUUID();
|
||||
const notification = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
t.is(notification.read, false);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
const updatedNotification = await t.context.models.notification.get(
|
||||
notification.id
|
||||
);
|
||||
t.is(updatedNotification!.read, true);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, order by createdAt descending', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
t.is(notifications.length, 2);
|
||||
t.is(notifications[0].id, notification2.id);
|
||||
t.is(notifications[1].id, notification1.id);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, filter read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
const inviteId = randomUUID();
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId,
|
||||
},
|
||||
});
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
t.is(notifications.length, 1);
|
||||
t.is(notifications[0].id, notification1.id);
|
||||
});
|
||||
|
||||
test('should clean expired notifications', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
t.truthy(notification);
|
||||
let notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id
|
||||
);
|
||||
t.is(notifications.length, 1);
|
||||
let count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
notifications = await t.context.models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 1);
|
||||
t.is(notifications[0].id, notification.id);
|
||||
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 1);
|
||||
notifications = await t.context.models.notification.findManyByUserId(user.id);
|
||||
t.is(notifications.length, 0);
|
||||
});
|
||||
|
||||
test('should not clean unexpired notifications', async t => {
|
||||
const notification = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
let count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
await t.context.models.notification.markAsRead(notification.id, user.id);
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 0);
|
||||
});
|
||||
|
||||
test('should find many notifications by user id, order by createdAt descending, with pagination', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notification3 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notification4 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
const notifications = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 0,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
t.is(notifications.length, 2);
|
||||
t.is(notifications[0].id, notification4.id);
|
||||
t.is(notifications[1].id, notification3.id);
|
||||
const notifications2 = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 2,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
t.is(notifications2.length, 2);
|
||||
t.is(notifications2[0].id, notification2.id);
|
||||
t.is(notifications2[1].id, notification1.id);
|
||||
const notifications3 = await t.context.models.notification.findManyByUserId(
|
||||
user.id,
|
||||
{
|
||||
offset: 4,
|
||||
first: 2,
|
||||
}
|
||||
);
|
||||
t.is(notifications3.length, 0);
|
||||
});
|
||||
|
||||
test('should count notifications by user id, exclude read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
t.truthy(notification1);
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
t.truthy(notification2);
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await t.context.models.notification.countByUserId(user.id);
|
||||
t.is(count, 1);
|
||||
});
|
||||
|
||||
test('should count notifications by user id, include read notifications', async t => {
|
||||
const notification1 = await t.context.models.notification.createMention({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
doc: {
|
||||
id: docId,
|
||||
title: 'doc-title',
|
||||
blockId: 'blockId',
|
||||
},
|
||||
createdByUserId: createdBy.id,
|
||||
},
|
||||
});
|
||||
t.truthy(notification1);
|
||||
const notification2 = await t.context.models.notification.createInvitation({
|
||||
userId: user.id,
|
||||
body: {
|
||||
workspaceId: workspace.id,
|
||||
createdByUserId: createdBy.id,
|
||||
inviteId: randomUUID(),
|
||||
},
|
||||
});
|
||||
t.truthy(notification2);
|
||||
await t.context.models.notification.markAsRead(notification2.id, user.id);
|
||||
const count = await t.context.models.notification.countByUserId(user.id, {
|
||||
includeRead: true,
|
||||
});
|
||||
t.is(count, 2);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { DocModel } from './doc';
|
||||
import { DocUserModel } from './doc-user';
|
||||
import { FeatureModel } from './feature';
|
||||
import { HistoryModel } from './history';
|
||||
import { NotificationModel } from './notification';
|
||||
import { MODELS_SYMBOL } from './provider';
|
||||
import { SessionModel } from './session';
|
||||
import { UserModel } from './user';
|
||||
@@ -34,6 +35,7 @@ const MODELS = {
|
||||
workspaceUser: WorkspaceUserModel,
|
||||
docUser: DocUserModel,
|
||||
history: HistoryModel,
|
||||
notification: NotificationModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
@@ -90,6 +92,7 @@ export * from './doc';
|
||||
export * from './doc-user';
|
||||
export * from './feature';
|
||||
export * from './history';
|
||||
export * from './notification';
|
||||
export * from './session';
|
||||
export * from './user';
|
||||
export * from './user-doc';
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Notification,
|
||||
NotificationLevel,
|
||||
NotificationType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export { NotificationLevel, NotificationType };
|
||||
export type { Notification };
|
||||
|
||||
// #region input
|
||||
|
||||
export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
const IdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
export const BaseNotificationCreateSchema = z.object({
|
||||
userId: IdSchema,
|
||||
level: z
|
||||
.nativeEnum(NotificationLevel)
|
||||
.optional()
|
||||
.default(NotificationLevel.Default),
|
||||
});
|
||||
|
||||
export const MentionDocSchema = z.object({
|
||||
id: IdSchema,
|
||||
// Allow empty string, will display as `Untitled` at frontend
|
||||
title: z.string().trim().max(255),
|
||||
// blockId or elementId is required at least one
|
||||
blockId: IdSchema.optional(),
|
||||
elementId: IdSchema.optional(),
|
||||
});
|
||||
|
||||
const MentionNotificationBodySchema = z.object({
|
||||
workspaceId: IdSchema,
|
||||
createdByUserId: IdSchema,
|
||||
doc: MentionDocSchema,
|
||||
});
|
||||
|
||||
export type MentionNotificationBody = z.infer<
|
||||
typeof MentionNotificationBodySchema
|
||||
>;
|
||||
|
||||
export const MentionNotificationCreateSchema =
|
||||
BaseNotificationCreateSchema.extend({
|
||||
body: MentionNotificationBodySchema,
|
||||
});
|
||||
|
||||
export type MentionNotificationCreate = z.input<
|
||||
typeof MentionNotificationCreateSchema
|
||||
>;
|
||||
|
||||
const InvitationNotificationBodySchema = z.object({
|
||||
workspaceId: IdSchema,
|
||||
createdByUserId: IdSchema,
|
||||
inviteId: IdSchema,
|
||||
});
|
||||
|
||||
export type InvitationNotificationBody = z.infer<
|
||||
typeof InvitationNotificationBodySchema
|
||||
>;
|
||||
|
||||
export const InvitationNotificationCreateSchema =
|
||||
BaseNotificationCreateSchema.extend({
|
||||
body: InvitationNotificationBodySchema,
|
||||
});
|
||||
|
||||
export type InvitationNotificationCreate = z.input<
|
||||
typeof InvitationNotificationCreateSchema
|
||||
>;
|
||||
|
||||
export type UnionNotificationBody =
|
||||
| MentionNotificationBody
|
||||
| InvitationNotificationBody;
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region output
|
||||
|
||||
export type MentionNotification = Notification &
|
||||
z.infer<typeof MentionNotificationCreateSchema>;
|
||||
|
||||
export type InvitationNotification = Notification &
|
||||
z.infer<typeof InvitationNotificationCreateSchema>;
|
||||
|
||||
export type UnionNotification = MentionNotification | InvitationNotification;
|
||||
|
||||
// #endregion
|
||||
|
||||
@Injectable()
|
||||
export class NotificationModel extends BaseModel {
|
||||
// #region mention
|
||||
|
||||
async createMention(input: MentionNotificationCreate) {
|
||||
const data = MentionNotificationCreateSchema.parse(input);
|
||||
const row = await this.create({
|
||||
userId: data.userId,
|
||||
level: data.level,
|
||||
type: NotificationType.Mention,
|
||||
body: data.body,
|
||||
});
|
||||
this.logger.log(
|
||||
`Created mention notification:${row.id} for user:${data.userId} in workspace:${data.body.workspaceId}`
|
||||
);
|
||||
return row as MentionNotification;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region invitation
|
||||
|
||||
async createInvitation(
|
||||
input: InvitationNotificationCreate,
|
||||
type: NotificationType = NotificationType.Invitation
|
||||
) {
|
||||
const data = InvitationNotificationCreateSchema.parse(input);
|
||||
const row = await this.create({
|
||||
userId: data.userId,
|
||||
level: data.level,
|
||||
type,
|
||||
body: data.body,
|
||||
});
|
||||
this.logger.log(
|
||||
`Created ${type} notification ${row.id} to user ${data.userId} in workspace ${data.body.workspaceId}`
|
||||
);
|
||||
return row as InvitationNotification;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region common
|
||||
|
||||
private async create(data: Prisma.NotificationUncheckedCreateInput) {
|
||||
return await this.db.notification.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userId: string) {
|
||||
await this.db.notification.update({
|
||||
where: { id: notificationId, userId },
|
||||
data: {
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many notifications by user id, exclude read notifications by default
|
||||
*/
|
||||
async findManyByUserId(
|
||||
userId: string,
|
||||
options?: {
|
||||
includeRead?: boolean;
|
||||
} & PaginationInput
|
||||
) {
|
||||
const rows = await this.db.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.includeRead ? {} : { read: false }),
|
||||
...(options?.after ? { createdAt: { gt: options.after } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: options?.offset,
|
||||
take: options?.first,
|
||||
});
|
||||
return rows as UnionNotification[];
|
||||
}
|
||||
|
||||
async countByUserId(userId: string, options: { includeRead?: boolean } = {}) {
|
||||
return this.db.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
...(options.includeRead ? {} : { read: false }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async get(notificationId: string) {
|
||||
const row = await this.db.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
return row as UnionNotification;
|
||||
}
|
||||
|
||||
async cleanExpiredNotifications() {
|
||||
const { count } = await this.db.notification.deleteMany({
|
||||
// delete notifications that are older than one year
|
||||
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } },
|
||||
});
|
||||
this.logger.log(`Deleted ${count} expired notifications`);
|
||||
return count;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
Reference in New Issue
Block a user