diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 43ce7204e..a6ce6da9c 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -37,6 +37,7 @@ import { StorageModule } from './core/storage'; import { SyncModule } from './core/sync'; import { UserModule } from './core/user'; import { WorkspaceModule } from './core/workspaces'; +import { ModelModules } from './models'; import { REGISTERED_PLUGINS } from './plugins'; import { ENABLED_PLUGINS } from './plugins/registry'; @@ -154,6 +155,7 @@ export function buildAppModule() { factor // basic .use(...FunctionalityModules) + .use(ModelModules) .useIf(config => config.flavor.sync, WebSocketModule) // auth diff --git a/packages/backend/server/src/base/event/def.ts b/packages/backend/server/src/base/event/def.ts index 71d0e2417..870643404 100644 --- a/packages/backend/server/src/base/event/def.ts +++ b/packages/backend/server/src/base/event/def.ts @@ -36,15 +36,6 @@ export interface DocEvents { updated: Payload>; } -export interface UserEvents { - updated: Payload>; - deleted: Payload< - User & { - ownedWorkspaces: Workspace['id'][]; - } - >; -} - /** * Event definitions can be extended by * @@ -61,7 +52,6 @@ export interface UserEvents { export interface EventDefinitions { workspace: WorkspaceEvents; snapshot: DocEvents; - user: UserEvents; } export type EventKV = Flatten; diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts index 59b6522f6..08095e74b 100644 --- a/packages/backend/server/src/core/user/service.ts +++ b/packages/backend/server/src/core/user/service.ts @@ -233,6 +233,7 @@ export class UserService { } } + // @ts-expect-error will be removed this.emitter.emit('user.updated', user); return user; diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts new file mode 100644 index 000000000..8dbe77801 --- /dev/null +++ b/packages/backend/server/src/models/index.ts @@ -0,0 +1,17 @@ +import { Global, Injectable, Module } from '@nestjs/common'; + +import { UserModel } from './user'; + +const models = [UserModel] as const; + +@Injectable() +export class Models { + constructor(public readonly user: UserModel) {} +} + +@Global() +@Module({ + providers: [...models, Models], + exports: [Models], +}) +export class ModelModules {} diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts new file mode 100644 index 000000000..e579afd08 --- /dev/null +++ b/packages/backend/server/src/models/user.ts @@ -0,0 +1,309 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma, PrismaClient, type User, Workspace } from '@prisma/client'; +import { pick } from 'lodash-es'; + +import { + Config, + CryptoHelper, + EmailAlreadyUsed, + EventEmitter, + type EventPayload, + OnEvent, + WrongSignInCredentials, + WrongSignInMethod, +} from '../base'; +import type { Payload } from '../base/event/def'; +import { Permission } from '../core/permission'; +import { Quota_FreePlanV1_1 } from '../core/quota'; + +const publicUserSelect = { + id: true, + name: true, + email: true, + avatarUrl: true, +} satisfies Prisma.UserSelect; +type CreateUserInput = Omit & { name?: string }; +type UpdateUserInput = Omit, 'id'>; + +const defaultUserCreatingData = { + name: 'Unnamed', + // TODO(@forehalo): it's actually a external dependency for user + // how could we avoid user model's knowledge of feature? + features: { + create: { + reason: 'sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1_1, + }, + }, + }, + }, +}; + +declare module '../base/event/def' { + interface UserEvents { + created: Payload; + updated: Payload; + deleted: Payload< + User & { + // TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate + // dealing of owned workspaces of deleted users to workspace model + ownedWorkspaces: Workspace['id'][]; + } + >; + } + + interface EventDefinitions { + user: UserEvents; + } +} + +export type PublicUser = Pick; +export type { User }; + +@Injectable() +export class UserModel { + private readonly logger = new Logger(UserModel.name); + constructor( + private readonly db: PrismaClient, + private readonly crypto: CryptoHelper, + private readonly event: EventEmitter, + private readonly config: Config + ) {} + + async get(id: string) { + return this.db.user.findUnique({ + where: { id }, + }); + } + + async getPublicUser(id: string): Promise { + return this.db.user.findUnique({ + select: publicUserSelect, + where: { id }, + }); + } + + async getUserByEmail(email: string): Promise { + const rows = await this.db.$queryRaw` + SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt + FROM "users" + WHERE lower("email") = lower(${email}) + `; + + return rows[0] ?? null; + } + + async signIn(email: string, password: string): Promise { + const user = await this.getUserByEmail(email); + + if (!user) { + throw new WrongSignInCredentials({ email }); + } + + if (!user.password) { + throw new WrongSignInMethod(); + } + + const passwordMatches = await this.crypto.verifyPassword( + password, + user.password + ); + + if (!passwordMatches) { + throw new WrongSignInCredentials({ email }); + } + + return user; + } + + async getPublicUserByEmail(email: string): Promise { + const rows = await this.db.$queryRaw` + SELECT id, name, email, avatar_url as avatarUrl + FROM "users" + WHERE lower("email") = lower(${email}) + `; + + return rows[0] ?? null; + } + + toPublicUser(user: User): PublicUser { + return pick(user, Object.keys(publicUserSelect)) as any; + } + + async create(data: CreateUserInput) { + let user = await this.getUserByEmail(data.email); + + if (user) { + throw new EmailAlreadyUsed(); + } + + if (data.password) { + data.password = await this.crypto.encryptPassword(data.password); + } + + if (!data.name) { + data.name = data.email.split('@')[0]; + } + + user = await this.db.user.create({ + data: { + ...defaultUserCreatingData, + ...data, + }, + }); + + this.logger.debug(`User [${user.id}] created with email [${user.email}]`); + this.event.emit('user.created', user); + + return user; + } + + async update(id: string, data: UpdateUserInput) { + if (data.password) { + data.password = await this.crypto.encryptPassword(data.password); + } + + if (data.email) { + const user = await this.getUserByEmail(data.email); + if (user && user.id !== id) { + throw new EmailAlreadyUsed(); + } + } + + const user = await this.db.user.update({ + where: { id }, + data, + }); + + this.logger.debug(`User [${user.id}] updated`); + this.event.emit('user.updated', user); + return user; + } + + /** + * Mark a existing user or create a new one as registered and email verified. + * + * When user created by others invitation, we will leave it as unregistered. + */ + async fulfill(email: string, data: Omit = {}) { + const user = await this.getUserByEmail(email); + + if (!user) { + return this.create({ + email, + registered: true, + emailVerifiedAt: new Date(), + ...data, + }); + } else { + if (user.registered) { + delete data.registered; + } else { + data.registered = true; + } + + if (user.emailVerifiedAt) { + delete data.emailVerifiedAt; + } else { + data.emailVerifiedAt = new Date(); + } + + if (Object.keys(data).length) { + return await this.update(user.id, data); + } + } + + return user; + } + + async delete(id: string) { + const ownedWorkspaces = await this.db.workspaceUserPermission.findMany({ + where: { + userId: id, + type: Permission.Owner, + }, + }); + + const user = await this.db.user.delete({ where: { id } }); + + this.event.emit('user.deleted', { + ...user, + ownedWorkspaces: ownedWorkspaces.map(w => w.workspaceId), + }); + + return user; + } + + async pagination(skip: number = 0, take: number = 20, after?: Date) { + return this.db.user.findMany({ + where: { + createdAt: { + gt: after, + }, + }, + orderBy: { + createdAt: 'asc', + }, + skip, + take, + }); + } + + async count() { + return this.db.user.count(); + } + + @OnEvent('user.updated') + async onUserUpdated(user: EventPayload<'user.updated'>) { + const { enabled, customerIo } = this.config.metrics; + if (enabled && customerIo?.token) { + const payload = { + name: user.name, + email: user.email, + created_at: Number(user.createdAt) / 1000, + }; + try { + await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, { + method: 'PUT', + headers: { + Authorization: `Basic ${customerIo.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + } catch (e) { + this.logger.error('Failed to publish user update event:', e); + } + } + } + + @OnEvent('user.deleted') + async onUserDeleted(user: EventPayload<'user.deleted'>) { + const { enabled, customerIo } = this.config.metrics; + if (enabled && customerIo?.token) { + try { + if (user.emailVerifiedAt) { + // suppress email if email is verified + await fetch( + `https://track.customer.io/api/v1/customers/${user.email}/suppress`, + { + method: 'POST', + headers: { + Authorization: `Basic ${customerIo.token}`, + }, + } + ); + } + await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, { + method: 'DELETE', + headers: { Authorization: `Basic ${customerIo.token}` }, + }); + } catch (e) { + this.logger.error('Failed to publish user delete event:', e); + } + } + } +} diff --git a/packages/backend/server/tests/models/user.spec.ts b/packages/backend/server/tests/models/user.spec.ts new file mode 100644 index 000000000..6114f5cc8 --- /dev/null +++ b/packages/backend/server/tests/models/user.spec.ts @@ -0,0 +1,301 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { EmailAlreadyUsed } from '../../src/base'; +import { Permission } from '../../src/core/permission'; +import { UserModel } from '../../src/models/user'; +import { createTestingModule, initTestingDB } from '../utils'; + +interface Context { + module: TestingModule; + user: UserModel; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule({ + providers: [UserModel], + }); + + t.context.user = module.get(UserModel); + t.context.module = module; +}); + +test.beforeEach(async t => { + await initTestingDB(t.context.module.get(PrismaClient)); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should create a new user', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + t.is(user.email, 'test@affine.pro'); + + const user2 = await t.context.user.getUserByEmail('test@affine.pro'); + + t.not(user2, null); + t.is(user2!.email, 'test@affine.pro'); +}); + +test('should trigger user.created event', async t => { + const event = t.context.module.get(EventEmitter2); + const spy = Sinon.spy(); + event.on('user.created', spy); + + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + t.true(spy.calledOnceWithExactly(user)); +}); + +test('should sign in user with password', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + password: 'password', + }); + + const signedInUser = await t.context.user.signIn(user.email, 'password'); + + t.is(signedInUser.id, user.id); + // Password is encrypted + t.not(signedInUser.password, 'password'); +}); + +test('should update an user', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const user2 = await t.context.user.update(user.id, { + email: 'test2@affine.pro', + }); + + t.is(user2.email, 'test2@affine.pro'); +}); + +test('should update password', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + password: 'password', + }); + + const updatedUser = await t.context.user.update(user.id, { + password: 'new password', + }); + + t.not(updatedUser.password, user.password); + // password is encrypted + t.not(updatedUser.password, 'new password'); +}); + +test('should not update email to an existing one', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + const user2 = await t.context.user.create({ + email: 'test2@affine.pro', + }); + + await t.throwsAsync( + () => + t.context.user.update(user.id, { + email: user2.email, + }), + { + instanceOf: EmailAlreadyUsed, + } + ); +}); + +test('should trigger user.updated event', async t => { + const event = t.context.module.get(EventEmitter2); + const spy = Sinon.spy(); + event.on('user.updated', spy); + + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const updatedUser = await t.context.user.update(user.id, { + email: 'test2@affine.pro', + name: 'new name', + }); + + t.true(spy.calledOnceWithExactly(updatedUser)); +}); + +test('should get user by id', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const user2 = await t.context.user.get(user.id); + + t.not(user2, null); + t.is(user2!.id, user.id); +}); + +test('should get public user by id', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const publicUser = await t.context.user.getPublicUser(user.id); + + t.not(publicUser, null); + t.is(publicUser!.id, user.id); + t.true(!('password' in publicUser!)); +}); + +test('should get public user by email', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const publicUser = await t.context.user.getPublicUserByEmail(user.email); + + t.not(publicUser, null); + t.is(publicUser!.id, user.id); + t.true(!('password' in publicUser!)); +}); + +test('should get user by email', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const user2 = await t.context.user.getUserByEmail(user.email); + + t.not(user2, null); + t.is(user2!.id, user.id); +}); + +test('should ignore case when getting user by email', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + const user2 = await t.context.user.getUserByEmail('TEST@affine.pro'); + + t.not(user2, null); + t.is(user2!.id, user.id); +}); + +test('should return null for non existing user', async t => { + const user = await t.context.user.getUserByEmail('test@affine.pro'); + + t.is(user, null); +}); + +test('should fulfill user', async t => { + let user = await t.context.user.create({ + email: 'test@affine.pro', + registered: false, + }); + + t.is(user.registered, false); + t.is(user.emailVerifiedAt, null); + + user = await t.context.user.fulfill(user.email); + + t.is(user.registered, true); + t.not(user.emailVerifiedAt, null); + + const user2 = await t.context.user.fulfill('test2@affine.pro'); + + t.is(user2.registered, true); + t.not(user2.emailVerifiedAt, null); +}); + +test('should trigger user.updated event when fulfilling user', async t => { + const event = t.context.module.get(EventEmitter2); + const createSpy = Sinon.spy(); + const updateSpy = Sinon.spy(); + event.on('user.created', createSpy); + event.on('user.updated', updateSpy); + + const user2 = await t.context.user.fulfill('test2@affine.pro'); + + t.true(createSpy.calledOnceWithExactly(user2)); + + let user = await t.context.user.create({ + email: 'test@affine.pro', + registered: false, + }); + + user = await t.context.user.fulfill(user.email); + + t.true(updateSpy.calledOnceWithExactly(user)); +}); + +test('should delete user', async t => { + const user = await t.context.user.create({ + email: 'test@affine.pro', + }); + + await t.context.user.delete(user.id); + + const user2 = await t.context.user.get(user.id); + + t.is(user2, null); +}); + +test('should trigger user.deleted event', async t => { + const event = t.context.module.get(EventEmitter2); + const spy = Sinon.spy(); + event.on('user.deleted', spy); + + const user = await t.context.user.create({ + email: 'test@affine.pro', + workspacePermissions: { + create: { + workspace: { + create: { + id: 'test-workspace', + public: false, + }, + }, + type: Permission.Owner, + }, + }, + }); + + await t.context.user.delete(user.id); + + t.true( + spy.calledOnceWithExactly({ ...user, ownedWorkspaces: ['test-workspace'] }) + ); +}); + +test('should paginate users', async t => { + const db = t.context.module.get(PrismaClient); + const now = Date.now(); + await Promise.all( + Array.from({ length: 100 }).map((_, i) => + db.user.create({ + data: { + name: `test${i}`, + email: `test${i}@affine.pro`, + createdAt: new Date(now + i), + }, + }) + ) + ); + + const users = await t.context.user.pagination(0, 10); + t.is(users.length, 10); + t.deepEqual( + users.map(user => user.email), + Array.from({ length: 10 }).map((_, i) => `test${i}@affine.pro`) + ); +}); diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 07298ea4b..71a11489b 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -13,6 +13,7 @@ import { GlobalExceptionFilter, Runtime } from '../../src/base'; import { GqlModule } from '../../src/base/graphql'; import { AuthGuard, AuthModule } from '../../src/core/auth'; import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init'; +import { ModelModules } from '../../src/models'; export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read'; @@ -79,6 +80,7 @@ export async function createTestingModule( ? [AppModule] : dedupeModules([ ...FunctionalityModules, + ModelModules, AuthModule, GqlModule, ...imports, diff --git a/tsconfig.json b/tsconfig.json index 6a171079d..3d9a96ba4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,6 @@ // Composite "composite": true, - "disableReferencedProjectLoad": true, // NOTE(@forehalo): // We now "fully" resolve package exports by standard "exports" field in package.json