feat(server): user model (#9608)
This commit is contained in:
@@ -37,6 +37,7 @@ import { StorageModule } from './core/storage';
|
|||||||
import { SyncModule } from './core/sync';
|
import { SyncModule } from './core/sync';
|
||||||
import { UserModule } from './core/user';
|
import { UserModule } from './core/user';
|
||||||
import { WorkspaceModule } from './core/workspaces';
|
import { WorkspaceModule } from './core/workspaces';
|
||||||
|
import { ModelModules } from './models';
|
||||||
import { REGISTERED_PLUGINS } from './plugins';
|
import { REGISTERED_PLUGINS } from './plugins';
|
||||||
import { ENABLED_PLUGINS } from './plugins/registry';
|
import { ENABLED_PLUGINS } from './plugins/registry';
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ export function buildAppModule() {
|
|||||||
factor
|
factor
|
||||||
// basic
|
// basic
|
||||||
.use(...FunctionalityModules)
|
.use(...FunctionalityModules)
|
||||||
|
.use(ModelModules)
|
||||||
.useIf(config => config.flavor.sync, WebSocketModule)
|
.useIf(config => config.flavor.sync, WebSocketModule)
|
||||||
|
|
||||||
// auth
|
// auth
|
||||||
|
|||||||
@@ -36,15 +36,6 @@ export interface DocEvents {
|
|||||||
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserEvents {
|
|
||||||
updated: Payload<Omit<User, 'password'>>;
|
|
||||||
deleted: Payload<
|
|
||||||
User & {
|
|
||||||
ownedWorkspaces: Workspace['id'][];
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event definitions can be extended by
|
* Event definitions can be extended by
|
||||||
*
|
*
|
||||||
@@ -61,7 +52,6 @@ export interface UserEvents {
|
|||||||
export interface EventDefinitions {
|
export interface EventDefinitions {
|
||||||
workspace: WorkspaceEvents;
|
workspace: WorkspaceEvents;
|
||||||
snapshot: DocEvents;
|
snapshot: DocEvents;
|
||||||
user: UserEvents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventKV = Flatten<EventDefinitions>;
|
export type EventKV = Flatten<EventDefinitions>;
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error will be removed
|
||||||
this.emitter.emit('user.updated', user);
|
this.emitter.emit('user.updated', user);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
17
packages/backend/server/src/models/index.ts
Normal file
17
packages/backend/server/src/models/index.ts
Normal file
@@ -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 {}
|
||||||
309
packages/backend/server/src/models/user.ts
Normal file
309
packages/backend/server/src/models/user.ts
Normal file
@@ -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<Prisma.UserCreateInput, 'name'> & { name?: string };
|
||||||
|
type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, '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<User>;
|
||||||
|
updated: Payload<User>;
|
||||||
|
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<User, keyof typeof publicUserSelect>;
|
||||||
|
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<PublicUser | null> {
|
||||||
|
return this.db.user.findUnique({
|
||||||
|
select: publicUserSelect,
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
const rows = await this.db.$queryRaw<User[]>`
|
||||||
|
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<User> {
|
||||||
|
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<PublicUser | null> {
|
||||||
|
const rows = await this.db.$queryRaw<PublicUser[]>`
|
||||||
|
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<UpdateUserInput, 'email'> = {}) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
301
packages/backend/server/tests/models/user.spec.ts
Normal file
301
packages/backend/server/tests/models/user.spec.ts
Normal file
@@ -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<Context>;
|
||||||
|
|
||||||
|
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`)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { GlobalExceptionFilter, Runtime } from '../../src/base';
|
|||||||
import { GqlModule } from '../../src/base/graphql';
|
import { GqlModule } from '../../src/base/graphql';
|
||||||
import { AuthGuard, AuthModule } from '../../src/core/auth';
|
import { AuthGuard, AuthModule } from '../../src/core/auth';
|
||||||
import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init';
|
import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init';
|
||||||
|
import { ModelModules } from '../../src/models';
|
||||||
|
|
||||||
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
|
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export async function createTestingModule(
|
|||||||
? [AppModule]
|
? [AppModule]
|
||||||
: dedupeModules([
|
: dedupeModules([
|
||||||
...FunctionalityModules,
|
...FunctionalityModules,
|
||||||
|
ModelModules,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
GqlModule,
|
GqlModule,
|
||||||
...imports,
|
...imports,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
|
|
||||||
// Composite
|
// Composite
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"disableReferencedProjectLoad": true,
|
|
||||||
|
|
||||||
// NOTE(@forehalo):
|
// NOTE(@forehalo):
|
||||||
// We now "fully" resolve package exports by standard "exports" field in package.json
|
// We now "fully" resolve package exports by standard "exports" field in package.json
|
||||||
|
|||||||
Reference in New Issue
Block a user