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

View File

@@ -6,12 +6,13 @@ import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features'; import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota'; 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'; import { createTestingModule, initTestingDB } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
user: UserService; models: Models;
u1: CurrentUser; u1: CurrentUser;
db: PrismaClient; db: PrismaClient;
m: TestingModule; m: TestingModule;
@@ -24,7 +25,7 @@ test.before(async t => {
}); });
t.context.auth = m.get(AuthService); 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.db = m.get(PrismaClient);
t.context.m = m; 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 => { 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', email: 'u2@affine.pro',
name: 'u2', name: 'u2',
}); });

View File

@@ -11,7 +11,7 @@ import { URLHelper } from '../../base';
import { ConfigModule } from '../../base/config'; import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { UserService } from '../../core/user'; import { Models } from '../../models';
import { OAuthProviderName } from '../../plugins/oauth/config'; import { OAuthProviderName } from '../../plugins/oauth/config';
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google'; import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
import { OAuthService } from '../../plugins/oauth/service'; import { OAuthService } from '../../plugins/oauth/service';
@@ -20,7 +20,7 @@ import { createTestingApp, getSession, initTestingDB } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
oauth: OAuthService; oauth: OAuthService;
user: UserService; models: Models;
u1: CurrentUser; u1: CurrentUser;
db: PrismaClient; db: PrismaClient;
app: INestApplication; app: INestApplication;
@@ -47,7 +47,7 @@ test.before(async t => {
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService); 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.db = app.get(PrismaClient);
t.context.app = app; 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 => { 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', name: 'u3',
email: 'u3@affine.pro', email: 'u3@affine.pro',
registered: false, registered: false,

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import ava from 'ava';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { MailService } from '../base/mailer'; import { MailService } from '../base/mailer';
import { AuthService } from '../core/auth/service'; import { AuthService } from '../core/auth/service';
import { UserService } from '../core/user'; import { Models } from '../models';
import { import {
acceptInviteById, acceptInviteById,
createTestingApp, createTestingApp,
@@ -27,7 +27,7 @@ const test = ava as TestFn<{
client: PrismaClient; client: PrismaClient;
auth: AuthService; auth: AuthService;
mail: MailService; mail: MailService;
user: UserService; models: Models;
}>; }>;
test.beforeEach(async t => { test.beforeEach(async t => {
@@ -38,7 +38,7 @@ test.beforeEach(async t => {
t.context.client = app.get(PrismaClient); t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService); t.context.mail = app.get(MailService);
t.context.user = app.get(UserService); t.context.models = app.get(Models);
}); });
test.afterEach.always(async t => { 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 => { 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 u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro'); 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.not(u2, undefined, 'failed to create user');
t.is(u2?.name, 'u2', '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 { 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 { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins'; import { REGISTERED_PLUGINS } from './plugins';
import { ENABLED_PLUGINS } from './plugins/registry'; import { ENABLED_PLUGINS } from './plugins/registry';
@@ -155,7 +155,7 @@ export function buildAppModule() {
factor factor
// basic // basic
.use(...FunctionalityModules) .use(...FunctionalityModules)
.use(ModelModules) .use(ModelsModule)
.useIf(config => config.flavor.sync, WebSocketModule) .useIf(config => config.flavor.sync, WebSocketModule)
// auth // auth

View File

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

View File

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

View File

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

View File

@@ -7,10 +7,12 @@ import {
InternalServerError, InternalServerError,
Mutex, Mutex,
PasswordRequired, PasswordRequired,
Runtime,
} from '../../base'; } from '../../base';
import { Models } from '../../models';
import { AuthService, Public } from '../auth'; import { AuthService, Public } from '../auth';
import { ServerService } from '../config'; import { ServerService } from '../config';
import { UserService } from '../user/service'; import { validators } from '../utils/validators';
interface CreateUserInput { interface CreateUserInput {
email: string; email: string;
@@ -20,11 +22,12 @@ interface CreateUserInput {
@Controller('/api/setup') @Controller('/api/setup')
export class CustomSetupController { export class CustomSetupController {
constructor( constructor(
private readonly user: UserService, private readonly models: Models,
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly event: EventEmitter, private readonly event: EventEmitter,
private readonly mutex: Mutex, private readonly mutex: Mutex,
private readonly server: ServerService private readonly server: ServerService,
private readonly runtime: Runtime
) {} ) {}
@Public() @Public()
@@ -34,21 +37,33 @@ export class CustomSetupController {
@Res() res: Response, @Res() res: Response,
@Body() input: CreateUserInput @Body() input: CreateUserInput
) { ) {
if (await this.server.initialized()) {
throw new ActionForbidden('First user already created');
}
validators.assertValidEmail(input.email);
if (!input.password) { if (!input.password) {
throw new PasswordRequired(); 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'); await using lock = await this.mutex.acquire('createFirstAdmin');
if (!lock) { if (!lock) {
throw new InternalServerError(); throw new InternalServerError();
} }
if (await this.server.initialized()) { const user = await this.models.user.create({
throw new ActionForbidden('First user already created');
}
const user = await this.user.createUser({
email: input.email, email: input.email,
password: input.password, password: input.password,
registered: true, registered: true,
@@ -59,7 +74,7 @@ export class CustomSetupController {
await this.auth.setCookies(req, res, user.id); await this.auth.setCookies(req, res, user.id);
res.send({ id: user.id, email: user.email, name: user.name }); res.send({ id: user.id, email: user.email, name: user.name });
} catch (e) { } catch (e) {
await this.user.deleteUser(user.id); await this.models.user.delete(user.id);
throw e; 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 { PermissionModule } from '../permission';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { UserAvatarController } from './controller'; import { UserAvatarController } from './controller';
import { UserEventsListener } from './event';
import { UserManagementResolver, UserResolver } from './resolver'; import { UserManagementResolver, UserResolver } from './resolver';
import { UserService } from './service';
@Module({ @Module({
imports: [StorageModule, PermissionModule], imports: [StorageModule, PermissionModule],
providers: [UserResolver, UserService, UserManagementResolver], providers: [UserResolver, UserManagementResolver, UserEventsListener],
controllers: [UserAvatarController], controllers: [UserAvatarController],
exports: [UserService],
}) })
export class UserModule {} export class UserModule {}
export { UserService } from './service';
export { UserType } from './types'; export { UserType } from './types';

View File

@@ -17,13 +17,13 @@ import {
Throttle, Throttle,
UserNotFound, UserNotFound,
} from '../../base'; } from '../../base';
import { Models } from '../../models';
import { Public } from '../auth/guard'; import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service'; import { sessionUser } from '../auth/service';
import { CurrentUser } from '../auth/session'; import { CurrentUser } from '../auth/session';
import { Admin } from '../common'; import { Admin } from '../common';
import { AvatarStorage } from '../storage'; import { AvatarStorage } from '../storage';
import { validators } from '../utils/validators'; import { validators } from '../utils/validators';
import { UserService } from './service';
import { import {
DeleteAccount, DeleteAccount,
ManageUserInput, ManageUserInput,
@@ -37,7 +37,7 @@ import {
export class UserResolver { export class UserResolver {
constructor( constructor(
private readonly storage: AvatarStorage, private readonly storage: AvatarStorage,
private readonly users: UserService private readonly models: Models
) {} ) {}
@Throttle('strict') @Throttle('strict')
@@ -54,7 +54,7 @@ export class UserResolver {
validators.assertValidEmail(email); validators.assertValidEmail(email);
// TODO(@forehalo): need to limit a user can only get another user witch is in the same workspace // 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 // return empty response when user not exists
if (!user) return null; if (!user) return null;
@@ -99,7 +99,7 @@ export class UserResolver {
await this.storage.delete(user.avatarUrl); await this.storage.delete(user.avatarUrl);
} }
return this.users.updateUser(user.id, { avatarUrl }); return this.models.user.update(user.id, { avatarUrl });
} }
@Mutation(() => UserType, { @Mutation(() => UserType, {
@@ -115,7 +115,7 @@ export class UserResolver {
return user; return user;
} }
return sessionUser(await this.users.updateUser(user.id, input)); return sessionUser(await this.models.user.update(user.id, input));
} }
@Mutation(() => RemoveAvatar, { @Mutation(() => RemoveAvatar, {
@@ -126,7 +126,7 @@ export class UserResolver {
if (!user) { if (!user) {
throw new UserNotFound(); throw new UserNotFound();
} }
await this.users.updateUser(user.id, { avatarUrl: null }); await this.models.user.update(user.id, { avatarUrl: null });
return { success: true }; return { success: true };
} }
@@ -134,7 +134,7 @@ export class UserResolver {
async deleteAccount( async deleteAccount(
@CurrentUser() user: CurrentUser @CurrentUser() user: CurrentUser
): Promise<DeleteAccount> { ): Promise<DeleteAccount> {
await this.users.deleteUser(user.id); await this.models.user.delete(user.id);
return { success: true }; return { success: true };
} }
} }
@@ -162,7 +162,7 @@ class CreateUserInput {
export class UserManagementResolver { export class UserManagementResolver {
constructor( constructor(
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly user: UserService private readonly models: Models
) {} ) {}
@Query(() => Int, { @Query(() => Int, {
@@ -178,11 +178,7 @@ export class UserManagementResolver {
async users( async users(
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput @Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
): Promise<UserType[]> { ): Promise<UserType[]> {
const users = await this.db.user.findMany({ const users = await this.models.user.pagination(input.skip, input.first);
select: { ...this.user.defaultUserSelect, password: true },
skip: input.skip,
take: input.first,
});
return users.map(sessionUser); return users.map(sessionUser);
} }
@@ -192,12 +188,7 @@ export class UserManagementResolver {
description: 'Get user by id', description: 'Get user by id',
}) })
async getUser(@Args('id') id: string) { async getUser(@Args('id') id: string) {
const user = await this.db.user.findUnique({ const user = await this.models.user.get(id);
select: { ...this.user.defaultUserSelect, password: true },
where: {
id,
},
});
if (!user) { if (!user) {
return null; return null;
@@ -212,12 +203,7 @@ export class UserManagementResolver {
nullable: true, nullable: true,
}) })
async getUserByEmail(@Args('email') email: string) { async getUserByEmail(@Args('email') email: string) {
const user = await this.db.user.findUnique({ const user = await this.models.user.getUserByEmail(email);
select: { ...this.user.defaultUserSelect, password: true },
where: {
email,
},
});
if (!user) { if (!user) {
return null; return null;
@@ -232,7 +218,7 @@ export class UserManagementResolver {
async createUser( async createUser(
@Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput @Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput
) { ) {
const { id } = await this.user.createUser({ const { id } = await this.models.user.create({
email: input.email, email: input.email,
registered: true, registered: true,
}); });
@@ -251,7 +237,7 @@ export class UserManagementResolver {
if (user.id === id) { if (user.id === id) {
throw new CannotDeleteOwnAccount(); throw new CannotDeleteOwnAccount();
} }
await this.user.deleteUser(id); await this.models.user.delete(id);
return { success: true }; return { success: true };
} }
@@ -276,7 +262,7 @@ export class UserManagementResolver {
} }
return sessionUser( return sessionUser(
await this.user.updateUser(user.id, { await this.models.user.update(user.id, {
email: input.email, email: input.email,
name: input.name, 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 { getStreamAsBuffer } from 'get-stream';
import { Cache, MailService, UserNotFound } from '../../../base'; import { Cache, MailService, UserNotFound } from '../../../base';
import { Models } from '../../../models';
import { DocContentService } from '../../doc-renderer'; import { DocContentService } from '../../doc-renderer';
import { Permission, PermissionService } from '../../permission'; import { Permission, PermissionService } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
import { UserService } from '../../user';
export const defaultWorkspaceAvatar = 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'; '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 mailer: MailService,
private readonly permission: PermissionService, private readonly permission: PermissionService,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly user: UserService private readonly models: Models
) {} ) {}
async getInviteInfo(inviteId: string): Promise<InviteInfo> { async getInviteInfo(inviteId: string): Promise<InviteInfo> {
@@ -92,7 +92,7 @@ export class WorkspaceService {
return; return;
} }
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.user.findUserById(inviteeUserId); const invitee = await this.models.user.getPublicUser(inviteeUserId);
if (!invitee) { if (!invitee) {
this.logger.error( this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}` `Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
@@ -111,10 +111,10 @@ export class WorkspaceService {
await this.getInviteInfo(inviteId); await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = inviteeUserId const invitee = inviteeUserId
? await this.user.findUserById(inviteeUserId) ? await this.models.user.getPublicUser(inviteeUserId)
: null; : null;
const inviter = inviterUserId const inviter = inviterUserId
? await this.user.findUserById(inviterUserId) ? await this.models.user.getPublicUser(inviterUserId)
: await this.permission.getWorkspaceOwner(workspaceId); : await this.permission.getWorkspaceOwner(workspaceId);
if (!inviter || !invitee) { if (!inviter || !invitee) {
@@ -138,7 +138,7 @@ export class WorkspaceService {
return; return;
} }
const invitee = await this.user.findUserById(inviteeUserId); const invitee = await this.models.user.getPublicUser(inviteeUserId);
if (!invitee) { if (!invitee) {
this.logger.error( this.logger.error(
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
@@ -199,7 +199,7 @@ export class WorkspaceService {
userId: string, userId: string,
ws: { id: string; role: Permission } 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(); if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id); const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendRoleChangedEmail(user?.email, { await this.mailer.sendRoleChangedEmail(user?.email, {

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,6 @@ import {
CryptoHelper, CryptoHelper,
EmailAlreadyUsed, EmailAlreadyUsed,
EventEmitter, EventEmitter,
type EventPayload,
OnEvent,
WrongSignInCredentials, WrongSignInCredentials,
WrongSignInMethod, WrongSignInMethod,
} from '../base'; } from '../base';
@@ -248,55 +246,4 @@ export class UserModel extends BaseModel {
async count() { async count() {
return this.db.user.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, UnknownOauthProvider,
} from '../../base'; } from '../../base';
import { AuthService, Public } from '../../core/auth'; import { AuthService, Public } from '../../core/auth';
import { UserService } from '../../core/user'; import { Models } from '../../models';
import { OAuthProviderName } from './config'; import { OAuthProviderName } from './config';
import { OAuthAccount, Tokens } from './providers/def'; import { OAuthAccount, Tokens } from './providers/def';
import { OAuthProviderFactory } from './register'; import { OAuthProviderFactory } from './register';
@@ -29,7 +29,7 @@ export class OAuthController {
constructor( constructor(
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly oauth: OAuthService, private readonly oauth: OAuthService,
private readonly user: UserService, private readonly models: Models,
private readonly providerFactory: OAuthProviderFactory, private readonly providerFactory: OAuthProviderFactory,
private readonly db: PrismaClient private readonly db: PrismaClient
) {} ) {}
@@ -137,9 +137,7 @@ export class OAuthController {
return connectedUser.user; return connectedUser.user;
} }
const user = await this.user.fulfillUser(externalAccount.email, { const user = await this.models.user.fulfill(externalAccount.email, {
emailVerifiedAt: new Date(),
registered: true,
avatarUrl: externalAccount.avatarUrl, avatarUrl: externalAccount.avatarUrl,
}); });

View File

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

View File

@@ -20,7 +20,7 @@
"typeRoots": ["./tools/@types", "./node_modules/@types"], "typeRoots": ["./tools/@types", "./node_modules/@types"],
// Emit // Emit
"lib": ["ESNext"], "lib": ["ES2024"],
"target": "ES2024", "target": "ES2024",
"useDefineForClassFields": false, "useDefineForClassFields": false,
"declaration": true, "declaration": true,