feat(server): use user model (#9710)

This commit is contained in:
forehalo
2025-01-17 07:06:11 +00:00
parent a2d16f4b78
commit 44de4474c3
25 changed files with 179 additions and 521 deletions

View File

@@ -8,12 +8,11 @@ import { MailService } from '../../base';
import { AuthModule, CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
import { UserModule, UserService } from '../../core/user';
import { UserModule } from '../../core/user';
import { createTestingApp, getSession, sessionCookie } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
@@ -31,7 +30,6 @@ test.before(async t => {
});
t.context.auth = app.get(AuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;

View File

@@ -6,12 +6,13 @@ import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { UserModule, UserService } from '../../core/user';
import { UserModule } from '../../core/user';
import { Models } from '../../models';
import { createTestingModule, initTestingDB } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
models: Models;
u1: CurrentUser;
db: PrismaClient;
m: TestingModule;
@@ -24,7 +25,7 @@ test.before(async t => {
});
t.context.auth = m.get(AuthService);
t.context.user = m.get(UserService);
t.context.models = m.get(Models);
t.context.db = m.get(PrismaClient);
t.context.m = m;
});
@@ -55,9 +56,9 @@ test('should throw if user not found', async t => {
});
test('should throw if password not set', async t => {
const { user, auth } = t.context;
const { models, auth } = t.context;
await user.createUser({
await models.user.create({
email: 'u2@affine.pro',
name: 'u2',
});

View File

@@ -11,7 +11,7 @@ import { URLHelper } from '../../base';
import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { UserService } from '../../core/user';
import { Models } from '../../models';
import { OAuthProviderName } from '../../plugins/oauth/config';
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
import { OAuthService } from '../../plugins/oauth/service';
@@ -20,7 +20,7 @@ import { createTestingApp, getSession, initTestingDB } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
oauth: OAuthService;
user: UserService;
models: Models;
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
@@ -47,7 +47,7 @@ test.before(async t => {
t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService);
t.context.user = app.get(UserService);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.app = app;
});
@@ -309,9 +309,9 @@ test('should not throw if account registered', async t => {
});
test('should be able to fullfil user with oauth sign in', async t => {
const { app, user, db } = t.context;
const { app, models, db } = t.context;
const u3 = await user.createUser({
const u3 = await models.user.create({
name: 'u3',
email: 'u3@affine.pro',
registered: false,

View File

@@ -234,11 +234,7 @@ test.before(async t => {
test.beforeEach(async t => {
const { db, app, stripe } = t.context;
Sinon.reset();
await initTestingDB(db);
t.context.runtime.fetch
.withArgs('plugins.payment/showLifetimePrice')
.resolves(true);
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
await db.workspace.create({
@@ -254,7 +250,13 @@ test.beforeEach(async t => {
},
});
Sinon.reset();
// default stubs
t.context.runtime.fetch
.withArgs('plugins.payment/showLifetimePrice')
.resolves(true);
// @ts-expect-error stub
stripe.prices.list.callsFake((params: Stripe.PriceListParams) => {
if (params.lookup_keys) {

View File

@@ -7,7 +7,8 @@ import {
type CurrentUser,
} from '../../core/auth';
import { sessionUser } from '../../core/auth/service';
import { UserService, type UserType } from '../../core/user';
import { UserType } from '../../core/user';
import { Models } from '../../models';
import { gql } from './common';
export type UserAuthedType = UserType & { token: ClientTokenType };
@@ -52,7 +53,7 @@ export async function signUp(
password: string,
autoVerifyEmail = true
): Promise<UserAuthedType> {
const user = await app.get(UserService).createUser({
const user = await app.get(Models).user.create({
name,
email,
password,

View File

@@ -13,7 +13,7 @@ import { GlobalExceptionFilter, Runtime } from '../../base';
import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth';
import { UserFeaturesInit1698652531198 } from '../../data/migrations/1698652531198-user-features-init';
import { ModelModules } from '../../models';
import { ModelsModule } from '../../models';
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
@@ -80,7 +80,7 @@ export async function createTestingModule(
? [AppModule]
: dedupeModules([
...FunctionalityModules,
ModelModules,
ModelsModule,
AuthModule,
GqlModule,
...imports,

View File

@@ -10,7 +10,7 @@ import ava from 'ava';
import { AppModule } from '../app.module';
import { MailService } from '../base/mailer';
import { AuthService } from '../core/auth/service';
import { UserService } from '../core/user';
import { Models } from '../models';
import {
acceptInviteById,
createTestingApp,
@@ -27,7 +27,7 @@ const test = ava as TestFn<{
client: PrismaClient;
auth: AuthService;
mail: MailService;
user: UserService;
models: Models;
}>;
test.beforeEach(async t => {
@@ -38,7 +38,7 @@ test.beforeEach(async t => {
t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService);
t.context.user = app.get(UserService);
t.context.models = app.get(Models);
});
test.afterEach.always(async t => {
@@ -87,14 +87,14 @@ test('should revoke a user', async t => {
});
test('should create user if not exist', async t => {
const { app, user } = t.context;
const { app, models } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro');
const u2 = await user.findUserByEmail('u2@affine.pro');
const u2 = await models.user.getUserByEmail('u2@affine.pro');
t.not(u2, undefined, 'failed to create user');
t.is(u2?.name, 'u2', 'failed to create user');
});

View File

@@ -37,7 +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 { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins';
import { ENABLED_PLUGINS } from './plugins/registry';
@@ -155,7 +155,7 @@ export function buildAppModule() {
factor
// basic
.use(...FunctionalityModules)
.use(ModelModules)
.use(ModelsModule)
.useIf(config => config.flavor.sync, WebSocketModule)
// auth

View File

@@ -27,7 +27,6 @@ import {
UseNamedGuard,
} from '../../base';
import { Models, TokenType } from '../../models';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { Public } from './guard';
import { AuthService } from './service';
@@ -56,7 +55,6 @@ export class AuthController {
constructor(
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly models: Models,
private readonly config: Config,
private readonly runtime: Runtime
@@ -81,9 +79,7 @@ export class AuthController {
}
validators.assertValidEmail(params.email);
const user = await this.user.findUserWithHashedPasswordByEmail(
params.email
);
const user = await this.models.user.getUserByEmail(params.email);
const magicLinkAvailable = !!this.config.mailer.host;
@@ -159,7 +155,7 @@ export class AuthController {
redirectUrl?: string
) {
// send email magic link
const user = await this.user.findUserByEmail(email);
const user = await this.models.user.getUserByEmail(email);
if (!user) {
const allowSignup = await this.runtime.fetch('auth/allowSignup');
if (!allowSignup) {
@@ -263,10 +259,7 @@ export class AuthController {
throw new InvalidEmailToken();
}
const user = await this.user.fulfillUser(email, {
emailVerifiedAt: new Date(),
registered: true,
});
const user = await this.models.user.fulfill(email);
await this.auth.setCookies(req, res, user.id);
res.send({ id: user.id });

View File

@@ -23,7 +23,6 @@ import {
} from '../../base';
import { Models, TokenType } from '../../models';
import { Admin } from '../common';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { Public } from './guard';
@@ -48,7 +47,6 @@ export class AuthResolver {
constructor(
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly models: Models
) {}
@@ -234,7 +232,7 @@ export class AuthResolver {
throw new InvalidEmailToken();
}
const hasRegistered = await this.user.findUserByEmail(email);
const hasRegistered = await this.models.user.getUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {

View File

@@ -7,7 +7,6 @@ import { Models, type User, type UserSession } from '../../models';
import { FeatureManagementService } from '../features/management';
import { QuotaService } from '../quota/service';
import { QuotaType } from '../quota/types';
import { UserService } from '../user/service';
import type { CurrentUser } from './session';
export function sessionUser(
@@ -47,17 +46,16 @@ export class AuthService implements OnApplicationBootstrap {
private readonly models: Models,
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService,
private readonly user: UserService
private readonly quota: QuotaService
) {}
async onApplicationBootstrap() {
if (this.config.node.dev) {
try {
const [email, name, password] = ['dev@affine.pro', 'Dev User', 'dev'];
let devUser = await this.user.findUserByEmail(email);
let devUser = await this.models.user.getUserByEmail(email);
if (!devUser) {
devUser = await this.user.createUser_without_verification({
devUser = await this.models.user.create({
email,
name,
password,
@@ -86,8 +84,8 @@ export class AuthService implements OnApplicationBootstrap {
);
}
return this.user
.createUser_without_verification({
return this.models.user
.create({
email,
password,
})
@@ -95,7 +93,7 @@ export class AuthService implements OnApplicationBootstrap {
}
async signIn(email: string, password: string): Promise<CurrentUser> {
return this.user.signIn(email, password).then(sessionUser);
return this.models.user.signIn(email, password).then(sessionUser);
}
async signOut(sessionId: string, userId?: string) {
@@ -131,7 +129,7 @@ export class AuthService implements OnApplicationBootstrap {
userSession = sessions.at(-1)!;
}
const user = await this.user.findUserById(userSession.userId);
const user = await this.models.user.get(userSession.userId);
if (!user) {
return null;
@@ -284,25 +282,23 @@ export class AuthService implements OnApplicationBootstrap {
id: string,
newPassword: string
): Promise<Omit<User, 'password'>> {
return this.user.updateUser(id, { password: newPassword });
return this.models.user.update(id, { password: newPassword });
}
async changeEmail(
id: string,
newEmail: string
): Promise<Omit<User, 'password'>> {
return this.user.updateUser(id, {
return this.models.user.update(id, {
email: newEmail,
emailVerifiedAt: new Date(),
});
}
async setEmailVerified(id: string) {
return await this.user.updateUser(
id,
{ emailVerifiedAt: new Date() },
{ emailVerifiedAt: true }
);
return await this.models.user.update(id, {
emailVerifiedAt: new Date(),
});
}
async sendChangePasswordEmail(email: string, callbackUrl: string) {

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { type EventPayload, OnEvent, Runtime } from '../../base';
import { UserService } from '../user/service';
import { Models } from '../../models';
import { FeatureService } from './service';
import { FeatureType } from './types';
@@ -18,7 +18,7 @@ export class FeatureManagementService {
constructor(
private readonly feature: FeatureService,
private readonly user: UserService,
private readonly models: Models,
private readonly runtime: Runtime
) {}
@@ -100,7 +100,7 @@ export class FeatureManagementService {
);
if (earlyAccessControlEnabled && !this.isStaff(email)) {
const user = await this.user.findUserByEmail(email);
const user = await this.models.user.getUserByEmail(email);
if (!user) {
return false;
}

View File

@@ -7,10 +7,12 @@ import {
InternalServerError,
Mutex,
PasswordRequired,
Runtime,
} from '../../base';
import { Models } from '../../models';
import { AuthService, Public } from '../auth';
import { ServerService } from '../config';
import { UserService } from '../user/service';
import { validators } from '../utils/validators';
interface CreateUserInput {
email: string;
@@ -20,11 +22,12 @@ interface CreateUserInput {
@Controller('/api/setup')
export class CustomSetupController {
constructor(
private readonly user: UserService,
private readonly models: Models,
private readonly auth: AuthService,
private readonly event: EventEmitter,
private readonly mutex: Mutex,
private readonly server: ServerService
private readonly server: ServerService,
private readonly runtime: Runtime
) {}
@Public()
@@ -34,21 +37,33 @@ export class CustomSetupController {
@Res() res: Response,
@Body() input: CreateUserInput
) {
if (await this.server.initialized()) {
throw new ActionForbidden('First user already created');
}
validators.assertValidEmail(input.email);
if (!input.password) {
throw new PasswordRequired();
}
const config = await this.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
validators.assertValidPassword(input.password, {
max: config['auth/password.max'],
min: config['auth/password.min'],
});
await using lock = await this.mutex.acquire('createFirstAdmin');
if (!lock) {
throw new InternalServerError();
}
if (await this.server.initialized()) {
throw new ActionForbidden('First user already created');
}
const user = await this.user.createUser({
const user = await this.models.user.create({
email: input.email,
password: input.password,
registered: true,
@@ -59,7 +74,7 @@ export class CustomSetupController {
await this.auth.setCookies(req, res, user.id);
res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) {
await this.user.deleteUser(user.id);
await this.models.user.delete(user.id);
throw e;
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config, type EventPayload, OnEvent } from '../../base';
@Injectable()
export class UserEventsListener {
private readonly logger = new Logger(UserEventsListener.name);
constructor(private readonly config: Config) {}
@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);
}
}
}
}

View File

@@ -3,16 +3,14 @@ import { Module } from '@nestjs/common';
import { PermissionModule } from '../permission';
import { StorageModule } from '../storage';
import { UserAvatarController } from './controller';
import { UserEventsListener } from './event';
import { UserManagementResolver, UserResolver } from './resolver';
import { UserService } from './service';
@Module({
imports: [StorageModule, PermissionModule],
providers: [UserResolver, UserService, UserManagementResolver],
providers: [UserResolver, UserManagementResolver, UserEventsListener],
controllers: [UserAvatarController],
exports: [UserService],
})
export class UserModule {}
export { UserService } from './service';
export { UserType } from './types';

View File

@@ -17,13 +17,13 @@ import {
Throttle,
UserNotFound,
} from '../../base';
import { Models } from '../../models';
import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service';
import { CurrentUser } from '../auth/session';
import { Admin } from '../common';
import { AvatarStorage } from '../storage';
import { validators } from '../utils/validators';
import { UserService } from './service';
import {
DeleteAccount,
ManageUserInput,
@@ -37,7 +37,7 @@ import {
export class UserResolver {
constructor(
private readonly storage: AvatarStorage,
private readonly users: UserService
private readonly models: Models
) {}
@Throttle('strict')
@@ -54,7 +54,7 @@ export class UserResolver {
validators.assertValidEmail(email);
// TODO(@forehalo): need to limit a user can only get another user witch is in the same workspace
const user = await this.users.findUserWithHashedPasswordByEmail(email);
const user = await this.models.user.getUserByEmail(email);
// return empty response when user not exists
if (!user) return null;
@@ -99,7 +99,7 @@ export class UserResolver {
await this.storage.delete(user.avatarUrl);
}
return this.users.updateUser(user.id, { avatarUrl });
return this.models.user.update(user.id, { avatarUrl });
}
@Mutation(() => UserType, {
@@ -115,7 +115,7 @@ export class UserResolver {
return user;
}
return sessionUser(await this.users.updateUser(user.id, input));
return sessionUser(await this.models.user.update(user.id, input));
}
@Mutation(() => RemoveAvatar, {
@@ -126,7 +126,7 @@ export class UserResolver {
if (!user) {
throw new UserNotFound();
}
await this.users.updateUser(user.id, { avatarUrl: null });
await this.models.user.update(user.id, { avatarUrl: null });
return { success: true };
}
@@ -134,7 +134,7 @@ export class UserResolver {
async deleteAccount(
@CurrentUser() user: CurrentUser
): Promise<DeleteAccount> {
await this.users.deleteUser(user.id);
await this.models.user.delete(user.id);
return { success: true };
}
}
@@ -162,7 +162,7 @@ class CreateUserInput {
export class UserManagementResolver {
constructor(
private readonly db: PrismaClient,
private readonly user: UserService
private readonly models: Models
) {}
@Query(() => Int, {
@@ -178,11 +178,7 @@ export class UserManagementResolver {
async users(
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
): Promise<UserType[]> {
const users = await this.db.user.findMany({
select: { ...this.user.defaultUserSelect, password: true },
skip: input.skip,
take: input.first,
});
const users = await this.models.user.pagination(input.skip, input.first);
return users.map(sessionUser);
}
@@ -192,12 +188,7 @@ export class UserManagementResolver {
description: 'Get user by id',
})
async getUser(@Args('id') id: string) {
const user = await this.db.user.findUnique({
select: { ...this.user.defaultUserSelect, password: true },
where: {
id,
},
});
const user = await this.models.user.get(id);
if (!user) {
return null;
@@ -212,12 +203,7 @@ export class UserManagementResolver {
nullable: true,
})
async getUserByEmail(@Args('email') email: string) {
const user = await this.db.user.findUnique({
select: { ...this.user.defaultUserSelect, password: true },
where: {
email,
},
});
const user = await this.models.user.getUserByEmail(email);
if (!user) {
return null;
@@ -232,7 +218,7 @@ export class UserManagementResolver {
async createUser(
@Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput
) {
const { id } = await this.user.createUser({
const { id } = await this.models.user.create({
email: input.email,
registered: true,
});
@@ -251,7 +237,7 @@ export class UserManagementResolver {
if (user.id === id) {
throw new CannotDeleteOwnAccount();
}
await this.user.deleteUser(id);
await this.models.user.delete(id);
return { success: true };
}
@@ -276,7 +262,7 @@ export class UserManagementResolver {
}
return sessionUser(
await this.user.updateUser(user.id, {
await this.models.user.update(user.id, {
email: input.email,
name: input.name,
})

View File

@@ -1,339 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, PrismaClient, User } from '@prisma/client';
import {
Config,
CryptoHelper,
EmailAlreadyUsed,
EventEmitter,
type EventPayload,
OnEvent,
Runtime,
WrongSignInCredentials,
WrongSignInMethod,
} from '../../base';
import { PermissionService } from '../permission';
import { Quota_FreePlanV1_1 } from '../quota/schema';
import { validators } from '../utils/validators';
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
defaultUserSelect = {
id: true,
name: true,
email: true,
emailVerifiedAt: true,
avatarUrl: true,
registered: true,
createdAt: true,
} satisfies Prisma.UserSelect;
constructor(
private readonly config: Config,
private readonly runtime: Runtime,
private readonly crypto: CryptoHelper,
private readonly prisma: PrismaClient,
private readonly emitter: EventEmitter,
private readonly permission: PermissionService
) {}
get userCreatingData() {
return {
name: 'Unnamed',
features: {
create: {
reason: 'sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
};
}
async createUser(data: CreateUserInput) {
validators.assertValidEmail(data.email);
if (data.password) {
const config = await this.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
validators.assertValidPassword(data.password, {
max: config['auth/password.max'],
min: config['auth/password.min'],
});
}
return this.createUser_without_verification(data);
}
async createUser_without_verification(data: CreateUserInput) {
const user = await this.findUserByEmail(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];
}
return this.prisma.user.create({
select: this.defaultUserSelect,
data: {
...this.userCreatingData,
...data,
},
});
}
async findUserById(id: string) {
return this.prisma.user
.findUnique({
where: { id },
select: this.defaultUserSelect,
})
.catch(() => {
return null;
});
}
async findUserByEmail(
email: string
): Promise<Pick<User, keyof typeof this.defaultUserSelect> | null> {
validators.assertValidEmail(email);
const rows = await this.prisma.$queryRaw<
// see [this.defaultUserSelect]
{
id: string;
name: string;
email: string;
email_verified: Date | null;
avatar_url: string | null;
registered: boolean;
created_at: Date;
}[]
>`
SELECT "id", "name", "email", "email_verified", "avatar_url", "registered", "created_at"
FROM "users"
WHERE lower("email") = lower(${email})
`;
const user = rows[0];
if (!user) {
return null;
}
return {
...user,
emailVerifiedAt: user.email_verified,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
}
/**
* supposed to be used only for `Credential SignIn`
*/
async findUserWithHashedPasswordByEmail(email: string): Promise<User | null> {
validators.assertValidEmail(email);
// see https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/raw-queries#typing-queryraw-results
const rows = await this.prisma.$queryRaw<
{
id: string;
name: string;
email: string;
password: string | null;
email_verified: Date | null;
avatar_url: string | null;
registered: boolean;
created_at: Date;
}[]
>`
SELECT *
FROM "users"
WHERE lower("email") = lower(${email})
`;
const user = rows[0];
if (!user) {
return null;
}
return {
...user,
emailVerifiedAt: user.email_verified,
avatarUrl: user.avatar_url,
createdAt: user.created_at,
};
}
async signIn(email: string, password: string) {
const user = await this.findUserWithHashedPasswordByEmail(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 fulfillUser(
email: string,
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>
) {
const user = await this.findUserByEmail(email);
if (!user) {
return this.createUser({
...this.userCreatingData,
email,
name: email.split('@')[0],
...data,
});
} else {
if (user.registered) {
delete data.registered;
}
if (user.emailVerifiedAt) {
delete data.emailVerifiedAt;
}
if (Object.keys(data).length) {
return await this.prisma.user.update({
where: { id: user.id },
data,
});
}
}
// @ts-expect-error will be removed
this.emitter.emit('user.updated', user);
return user;
}
async updateUser(
id: string,
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
select: Prisma.UserSelect = this.defaultUserSelect
) {
if (data.password) {
const config = await this.runtime.fetchAll({
'auth/password.max': true,
'auth/password.min': true,
});
validators.assertValidPassword(data.password, {
max: config['auth/password.max'],
min: config['auth/password.min'],
});
data.password = await this.crypto.encryptPassword(data.password);
}
if (data.email) {
validators.assertValidEmail(data.email);
const emailTaken = await this.prisma.user.count({
where: {
email: data.email,
id: {
not: id,
},
},
});
if (emailTaken) {
throw new EmailAlreadyUsed();
}
}
const user = await this.prisma.user.update({ where: { id }, data, select });
this.emitter.emit('user.updated', user);
return user;
}
async deleteUser(id: string) {
const ownedWorkspaces = await this.permission.getOwnedWorkspaces(id);
const user = await this.prisma.user.delete({ where: { id } });
this.emitter.emit('user.deleted', { ...user, ownedWorkspaces });
}
@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);
}
}
}
}

View File

@@ -3,10 +3,10 @@ import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { Cache, MailService, UserNotFound } from '../../../base';
import { Models } from '../../../models';
import { DocContentService } from '../../doc-renderer';
import { Permission, PermissionService } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage';
import { UserService } from '../../user';
export const defaultWorkspaceAvatar =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
@@ -35,7 +35,7 @@ export class WorkspaceService {
private readonly mailer: MailService,
private readonly permission: PermissionService,
private readonly prisma: PrismaClient,
private readonly user: UserService
private readonly models: Models
) {}
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
@@ -92,7 +92,7 @@ export class WorkspaceService {
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.user.findUserById(inviteeUserId);
const invitee = await this.models.user.getPublicUser(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
@@ -111,10 +111,10 @@ export class WorkspaceService {
await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = inviteeUserId
? await this.user.findUserById(inviteeUserId)
? await this.models.user.getPublicUser(inviteeUserId)
: null;
const inviter = inviterUserId
? await this.user.findUserById(inviterUserId)
? await this.models.user.getPublicUser(inviterUserId)
: await this.permission.getWorkspaceOwner(workspaceId);
if (!inviter || !invitee) {
@@ -138,7 +138,7 @@ export class WorkspaceService {
return;
}
const invitee = await this.user.findUserById(inviteeUserId);
const invitee = await this.models.user.getPublicUser(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
@@ -199,7 +199,7 @@ export class WorkspaceService {
userId: string,
ws: { id: string; role: Permission }
) {
const user = await this.user.findUserById(userId);
const user = await this.models.user.getPublicUser(userId);
if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendRoleChangedEmail(user?.email, {

View File

@@ -20,10 +20,10 @@ import {
URLHelper,
UserFriendlyError,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser } from '../../auth';
import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService } from '../../quota';
import { UserService } from '../../user';
import {
InviteLink,
InviteResult,
@@ -47,7 +47,7 @@ export class TeamWorkspaceResolver {
private readonly url: URLHelper,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly users: UserService,
private readonly models: Models,
private readonly quota: QuotaManagementService,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService
@@ -92,7 +92,7 @@ export class TeamWorkspaceResolver {
for (const [idx, email] of emails.entries()) {
const ret: InviteResult = { email, sentSuccess: false, inviteId: null };
try {
let target = await this.users.findUserByEmail(email);
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord =
await this.prisma.workspaceUserPermission.findFirst({
@@ -104,7 +104,7 @@ export class TeamWorkspaceResolver {
// only invite if the user is not already in the workspace
if (originRecord) continue;
} else {
target = await this.users.createUser({
target = await this.models.user.create({
email,
registered: false,
});
@@ -352,7 +352,7 @@ export class TeamWorkspaceResolver {
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
const user = await this.users.findUserById(userId);
const user = await this.models.user.getPublicUser(userId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(

View File

@@ -30,11 +30,12 @@ import {
UserFriendlyError,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { UserService, UserType } from '../../user';
import { UserType } from '../../user';
import {
InvitationType,
InviteUserType,
@@ -82,7 +83,7 @@ export class WorkspaceResolver {
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService,
private readonly users: UserService,
private readonly models: Models,
private readonly event: EventEmitter,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
@@ -407,7 +408,7 @@ export class WorkspaceResolver {
// member limit check
await this.quota.checkWorkspaceSeat(workspaceId);
let target = await this.users.findUserByEmail(email);
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord =
await this.prisma.workspaceUserPermission.findFirst({
@@ -419,7 +420,7 @@ export class WorkspaceResolver {
// only invite if the user is not already in the workspace
if (originRecord) return originRecord.id;
} else {
target = await this.users.createUser({
target = await this.models.user.create({
email,
registered: false,
});
@@ -480,7 +481,7 @@ export class WorkspaceResolver {
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.users.findUserById(inviteeId);
const invitee = await this.models.user.getPublicUser(inviteeId);
return { workspace, user: owner, invitee };
}

View File

@@ -71,7 +71,7 @@ const ModelsSymbolProvider: ExistingProvider = {
providers: [...Object.values(MODELS), ModelsProvider, ModelsSymbolProvider],
exports: [ModelsProvider],
})
export class ModelModules {}
export class ModelsModule {}
export * from './feature';
export * from './page';

View File

@@ -6,8 +6,6 @@ import {
CryptoHelper,
EmailAlreadyUsed,
EventEmitter,
type EventPayload,
OnEvent,
WrongSignInCredentials,
WrongSignInMethod,
} from '../base';
@@ -248,55 +246,4 @@ export class UserModel extends BaseModel {
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);
}
}
}
}

View File

@@ -18,7 +18,7 @@ import {
UnknownOauthProvider,
} from '../../base';
import { AuthService, Public } from '../../core/auth';
import { UserService } from '../../core/user';
import { Models } from '../../models';
import { OAuthProviderName } from './config';
import { OAuthAccount, Tokens } from './providers/def';
import { OAuthProviderFactory } from './register';
@@ -29,7 +29,7 @@ export class OAuthController {
constructor(
private readonly auth: AuthService,
private readonly oauth: OAuthService,
private readonly user: UserService,
private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory,
private readonly db: PrismaClient
) {}
@@ -137,9 +137,7 @@ export class OAuthController {
return connectedUser.user;
}
const user = await this.user.fulfillUser(externalAccount.email, {
emailVerifiedAt: new Date(),
registered: true,
const user = await this.models.user.fulfill(externalAccount.email, {
avatarUrl: externalAccount.avatarUrl,
});

View File

@@ -25,7 +25,7 @@ import {
} from '../../base';
import { CurrentUser } from '../../core/auth';
import { FeatureManagementService } from '../../core/features';
import { UserService } from '../../core/user';
import { Models } from '../../models';
import {
CheckoutParams,
Invoice,
@@ -75,7 +75,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
private readonly stripe: Stripe,
private readonly db: PrismaClient,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly models: Models,
private readonly userManager: UserSubscriptionManager,
private readonly workspaceManager: WorkspaceSubscriptionManager,
private readonly mutex: Mutex
@@ -435,7 +435,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null;
}
const user = await this.user.findUserByEmail(customer.email);
const user = await this.models.user.getPublicUserByEmail(customer.email);
if (!user) {
return null;
@@ -485,7 +485,9 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null;
}
const user = await this.user.findUserByEmail(invoice.customer_email);
const user = await this.models.user.getPublicUserByEmail(
invoice.customer_email
);
// TODO(@forehalo): the email may actually not appear to be AFFiNE user
// There is coming feature that allow anonymous user with only email provided to buy selfhost licenses