Files
AFFiNE-Mirror/packages/backend/server/src/models/user.ts
T
fengmk2 62d74de810 feat(server): search docs by keywork from indexer (#12863)
#### PR Dependency Tree


* **PR #12867**
  * **PR #12863** 👈
    * **PR #12837**
    * **PR #12866**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-06-20 09:48:30 +00:00

375 lines
9.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { type ConnectedAccount, Prisma, type User } from '@prisma/client';
import { omit } from 'lodash-es';
import {
CannotDeleteAccountWithOwnedTeamWorkspace,
CryptoHelper,
EmailAlreadyUsed,
EventBus,
UserNotFound,
WrongSignInCredentials,
WrongSignInMethod,
} from '../base';
import { BaseModel } from './base';
import { publicUserSelect, WorkspaceRole, workspaceUserSelect } from './common';
import type { Workspace } from './workspace';
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, 'id'>;
type CreateConnectedAccountInput = Omit<
Prisma.ConnectedAccountUncheckedCreateInput,
'id'
> & { accessToken: string };
type UpdateConnectedAccountInput = Omit<
Prisma.ConnectedAccountUncheckedUpdateInput,
'id'
>;
declare global {
interface Events {
'user.created': User;
'user.updated': User;
'user.deleted': User & {
// TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate
// dealing of owned workspaces of deleted users to workspace model
ownedWorkspaces: Workspace['id'][];
};
'user.postCreated': User;
}
}
interface UserFilter {
withDisabled?: boolean;
}
export interface ItemWithUserId {
userId: string;
}
export type PublicUser = Pick<User, keyof typeof publicUserSelect>;
export type WorkspaceUser = Pick<User, keyof typeof workspaceUserSelect>;
export type { ConnectedAccount, User };
@Injectable()
export class UserModel extends BaseModel {
constructor(
private readonly crypto: CryptoHelper,
private readonly event: EventBus
) {
super();
}
async get(id: string, filter: UserFilter = {}) {
return this.db.user.findUnique({
where: { id, disabled: filter.withDisabled ? undefined : false },
});
}
async getPublicUser(id: string): Promise<PublicUser | null> {
return this.db.user.findUnique({
select: publicUserSelect,
where: { id, disabled: false },
});
}
async getPublicUsers(ids: string[]): Promise<PublicUser[]> {
return this.db.user.findMany({
select: publicUserSelect,
where: { id: { in: ids }, disabled: false },
});
}
async getPublicUsersMap<T extends ItemWithUserId>(
items: T[]
): Promise<Map<string, PublicUser>> {
const userIds: string[] = [];
for (const item of items) {
if (item.userId) {
userIds.push(item.userId);
}
}
const users = await this.getPublicUsers(userIds);
return new Map(users.map(user => [user.id, user]));
}
async getWorkspaceUser(id: string): Promise<WorkspaceUser | null> {
return this.db.user.findUnique({
select: workspaceUserSelect,
where: { id, disabled: false },
});
}
async getWorkspaceUsers(ids: string[]): Promise<WorkspaceUser[]> {
return this.db.user.findMany({
select: workspaceUserSelect,
where: { id: { in: ids }, disabled: false },
});
}
async getUserByEmail(
email: string,
filter: UserFilter = {}
): 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", disabled
FROM "users"
WHERE lower("email") = lower(${email})
${Prisma.raw(filter.withDisabled ? '' : 'AND disabled = false')}
`;
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, avatar_url as "avatarUrl"
FROM "users"
WHERE lower("email") = lower(${email})
AND disabled = false
`;
return rows[0] ?? null;
}
async create(data: CreateUserInput) {
let user = await this.getUserByEmail(data.email, { withDisabled: true });
if (user) {
throw new EmailAlreadyUsed();
}
if (data.password) {
data.password = await this.crypto.encryptPassword(data.password);
}
user = await this.db.user.create({
data: {
...data,
name: data.name ?? data.email.split('@')[0],
},
});
// delegate the responsibility of finish user creating setup to the corresponding models
await this.event.emitAsync('user.postCreated', user);
this.logger.debug(`User [${user.id}] created with email [${user.email}]`);
this.event.emit('user.created', user);
return user;
}
async importUsers(inputs: CreateUserInput[]) {
return await Promise.allSettled(
inputs.map(async input => {
return await this.create({
...input,
registered: true,
});
})
);
}
@Transactional()
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, {
withDisabled: true,
});
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, { withDisabled: true });
if (!user) {
return this.create({
email,
registered: true,
emailVerifiedAt: new Date(),
...data,
});
} else {
if (user.disabled) {
throw new UserNotFound();
}
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 ownedWorkspaces(id: string) {
return await this.models.workspaceUser.getUserActiveRoles(id, {
role: WorkspaceRole.Owner,
});
}
async delete(id: string) {
const ownedWorkspaces = await this.ownedWorkspaces(id);
for (const ws of ownedWorkspaces) {
const isTeamWorkspace = await this.models.workspace.isTeamWorkspace(
ws.workspaceId
);
if (isTeamWorkspace) {
throw new CannotDeleteAccountWithOwnedTeamWorkspace();
}
}
const user = await this.db.user.delete({ where: { id } });
this.event.emit('user.deleted', {
...user,
ownedWorkspaces: ownedWorkspaces.map(r => r.workspaceId),
});
return user;
}
async ban(id: string) {
// ban an user barely share the same logic with delete an user,
// but keep the record with `disabled` flag
// we delete the account and create it again to trigger all cleanups
let user = await this.delete(id);
user = await this.db.user.create({
data: {
...omit(user, 'id'),
disabled: true,
},
});
await this.event.emitAsync('user.postCreated', user);
return user;
}
async enable(id: string) {
return await this.db.user.update({
where: { id },
data: { disabled: false },
});
}
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();
}
// #region ConnectedAccount
async createConnectedAccount(data: CreateConnectedAccountInput) {
const account = await this.db.connectedAccount.create({
data,
});
this.logger.debug(
`Connected account ${account.provider}:${account.id} created`
);
return account;
}
async getConnectedAccount(provider: string, providerAccountId: string) {
return await this.db.connectedAccount.findFirst({
where: { provider, providerAccountId },
include: {
user: true,
},
});
}
async updateConnectedAccount(id: string, data: UpdateConnectedAccountInput) {
return await this.db.connectedAccount.update({
where: { id },
data,
});
}
async deleteConnectedAccount(id: string) {
const { count } = await this.db.connectedAccount.deleteMany({
where: { id },
});
if (count > 0) {
this.logger.log(`Deleted connected account ${id}`);
}
return count;
}
// #endregion
}