diff --git a/.github/actions/deploy/deploy.mjs b/.github/actions/deploy/deploy.mjs index 8fad47eb68..4afce51374 100644 --- a/.github/actions/deploy/deploy.mjs +++ b/.github/actions/deploy/deploy.mjs @@ -14,6 +14,7 @@ const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CAPTCHA_TURNSTILE_SECRET, + METRICS_CUSTOMER_IO_TOKEN, COPILOT_OPENAI_API_KEY, COPILOT_FAL_API_KEY, COPILOT_UNSPLASH_API_KEY, @@ -117,6 +118,8 @@ const createHelmCommand = ({ isDryRun }) => { `--set-string graphql.app.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`, `--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`, `--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`, + `--set graphql.app.metrics.enabled=true`, + `--set-string graphql.app.metrics.customerIo.token="${METRICS_CUSTOMER_IO_TOKEN}"`, `--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`, `--set graphql.app.features.earlyAccessPreview=false`, `--set graphql.app.features.syncClientVersionCheck=true`, diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml index 580e35e5f8..7c1fd1dac8 100644 --- a/.github/helm/affine/charts/graphql/templates/deployment.yaml +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -191,6 +191,13 @@ spec: name: "{{ .Values.app.oauth.github.secretName }}" key: clientSecret {{ end }} + {{ if .Values.app.metrics.enabled }} + - name: METRICS_CUSTOMER_IO_TOKEN + valueFrom: + secretKeyRef: + name: "{{ .Values.app.metrics.secretName }}" + key: customerIoSecret + {{ end }} ports: - name: http containerPort: {{ .Values.service.port }} diff --git a/.github/helm/affine/charts/graphql/templates/metrics-secret.yaml b/.github/helm/affine/charts/graphql/templates/metrics-secret.yaml new file mode 100644 index 0000000000..3f38996260 --- /dev/null +++ b/.github/helm/affine/charts/graphql/templates/metrics-secret.yaml @@ -0,0 +1,9 @@ +{{- if .Values.app.metrics.enable -}} +apiVersion: v1 +kind: Secret +metadata: + name: "{{ .Values.app.metrics.secretName }}" +type: Opaque +data: + customerIoSecret: {{ .Values.app.metrics.customerIo.token | b64enc }} +{{- end }} diff --git a/.github/helm/affine/charts/graphql/values.yaml b/.github/helm/affine/charts/graphql/values.yaml index 625cb1b7e9..61b8ec3ea8 100644 --- a/.github/helm/affine/charts/graphql/values.yaml +++ b/.github/helm/affine/charts/graphql/values.yaml @@ -20,12 +20,12 @@ app: doc: mergeInterval: "3000" captcha: - enable: false + enabled: false secretName: captcha turnstile: secret: '' copilot: - enable: false + enabled: false secretName: copilot openai: key: '' @@ -54,6 +54,11 @@ app: user: '' password: '' sender: 'noreply@toeverything.info' + metrics: + enabled: false + secretName: 'metrics' + customerIo: + token: '' payment: stripe: secretName: 'stripe' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b453c5af9c..32f1ca77c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -137,6 +137,7 @@ jobs: COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }} COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }} COPILOT_UNSPLASH_API_KEY: ${{ secrets.COPILOT_UNSPLASH_API_KEY }} + METRICS_CUSTOMER_IO_TOKEN: ${{ secrets.METRICS_CUSTOMER_IO_TOKEN }} MAILER_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }} MAILER_USER: ${{ secrets.OAUTH_EMAIL_LOGIN }} MAILER_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }} diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index f88f3e4efa..d0c447a6a1 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -26,6 +26,7 @@ AFFiNE.ENV_MAP = { MAILER_SECURE: ['mailer.secure', 'boolean'], THROTTLE_TTL: ['rateLimiter.ttl', 'int'], THROTTLE_LIMIT: ['rateLimiter.limit', 'int'], + METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'], COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey', COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey', COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey', diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index d2a6a73598..21d79e5215 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -377,7 +377,10 @@ export class AuthService implements OnApplicationBootstrap { }); } - async changePassword(id: string, newPassword: string): Promise { + async changePassword( + id: string, + newPassword: string + ): Promise> { const user = await this.user.findUserById(id); if (!user) { @@ -386,46 +389,31 @@ export class AuthService implements OnApplicationBootstrap { const hashedPassword = await this.crypto.encryptPassword(newPassword); - return this.db.user.update({ - where: { - id: user.id, - }, - data: { - password: hashedPassword, - }, - }); + return this.user.updateUser(user.id, { password: hashedPassword }); } - async changeEmail(id: string, newEmail: string): Promise { + async changeEmail( + id: string, + newEmail: string + ): Promise> { const user = await this.user.findUserById(id); if (!user) { throw new BadRequestException('Invalid email'); } - return this.db.user.update({ - where: { - id, - }, - data: { - email: newEmail, - emailVerifiedAt: new Date(), - }, + return this.user.updateUser(id, { + email: newEmail, + emailVerifiedAt: new Date(), }); } async setEmailVerified(id: string) { - return await this.db.user.update({ - where: { - id, - }, - data: { - emailVerifiedAt: new Date(), - }, - select: { - emailVerifiedAt: true, - }, - }); + return await this.user.updateUser( + id, + { emailVerifiedAt: new Date() }, + { emailVerifiedAt: true } + ); } async sendChangePasswordEmail(email: string, callbackUrl: string) { diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index ec157ec61c..ff0b9ae553 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -117,7 +117,7 @@ export class UserResolver { throw new BadRequestException(`User not found`); } - const link = await this.storage.put( + const avatarUrl = await this.storage.put( `${user.id}-avatar`, avatar.createReadStream(), { @@ -125,12 +125,7 @@ export class UserResolver { } ); - return this.prisma.user.update({ - where: { id: user.id }, - data: { - avatarUrl: link, - }, - }); + return this.users.updateUser(user.id, { avatarUrl }); } @Mutation(() => UserType, { @@ -146,12 +141,7 @@ export class UserResolver { return user; } - return sessionUser( - await this.prisma.user.update({ - where: { id: user.id }, - data: input, - }) - ); + return sessionUser(await this.users.updateUser(user.id, input)); } @Mutation(() => RemoveAvatar, { @@ -162,10 +152,7 @@ export class UserResolver { if (!user) { throw new BadRequestException(`User not found`); } - await this.prisma.user.update({ - where: { id: user.id }, - data: { avatarUrl: null }, - }); + await this.users.updateUser(user.id, { avatarUrl: null }); return { success: true }; } diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts index cc1e263846..d854360810 100644 --- a/packages/backend/server/src/core/user/service.ts +++ b/packages/backend/server/src/core/user/service.ts @@ -1,10 +1,18 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { Prisma, PrismaClient } from '@prisma/client'; +import { + Config, + EventEmitter, + type EventPayload, + OnEvent, +} from '../../fundamentals'; import { Quota_FreePlanV1_1 } from '../quota/schema'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + defaultUserSelect = { id: true, name: true, @@ -12,9 +20,14 @@ export class UserService { emailVerifiedAt: true, avatarUrl: true, registered: true, + createdAt: true, } satisfies Prisma.UserSelect; - constructor(private readonly prisma: PrismaClient) {} + constructor( + private readonly config: Config, + private readonly prisma: PrismaClient, + private readonly emitter: EventEmitter + ) {} get userCreatingData() { return { @@ -139,10 +152,75 @@ export class UserService { } } + this.emitter.emit('user.updated', user); + + return user; + } + + async updateUser( + id: string, + data: Prisma.UserUpdateInput, + select: Prisma.UserSelect = this.defaultUserSelect + ) { + const user = await this.prisma.user.update({ where: { id }, data, select }); + + this.emitter.emit('user.updated', user); + return user; } async deleteUser(id: string) { return this.prisma.user.delete({ where: { id } }); } + + @OnEvent('user.updated') + async onUserUpdated(user: EventPayload<'user.deleted'>) { + const { enabled, customerIo } = this.config.metrics; + if (enabled && customerIo?.token) { + const payload = { + name: user.name, + email: user.email, + created_at: Number(user.createdAt), + }; + try { + await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, { + method: 'PUT', + headers: { + Authorization: `Basic ${customerIo.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + } catch (e) { + this.logger.error('Failed to publish user update event:', e); + } + } + } + + @OnEvent('user.deleted') + async onUserDeleted(user: EventPayload<'user.deleted'>) { + const { enabled, customerIo } = this.config.metrics; + if (enabled && customerIo?.token) { + try { + if (user.emailVerifiedAt) { + // suppress email if email is verified + await fetch( + `https://track.customer.io/api/v1/customers/${user.email}/suppress`, + { + method: 'POST', + headers: { + Authorization: `Basic ${customerIo.token}`, + }, + } + ); + } + await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, { + method: 'DELETE', + headers: { Authorization: `Basic ${customerIo.token}` }, + }); + } catch (e) { + this.logger.error('Failed to publish user delete event:', e); + } + } + } } diff --git a/packages/backend/server/src/data/app.ts b/packages/backend/server/src/data/app.ts index 7e594e8b95..e8160dd702 100644 --- a/packages/backend/server/src/data/app.ts +++ b/packages/backend/server/src/data/app.ts @@ -15,6 +15,7 @@ import { RevertCommand, RunCommand } from './commands/run'; }, metrics: { enabled: false, + customerIo: {}, }, }), BusinessAppModule, diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index bbb09b67bc..3f7f306048 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -340,6 +340,9 @@ export interface AFFiNEConfig { metrics: { enabled: boolean; + customerIo: { + token: string; + }; }; telemetry: { diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index 06cd8880ac..db38ef14e6 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -188,6 +188,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { }, metrics: { enabled: false, + customerIo: { + token: '', + }, }, telemetry: { enabled: isSelfhosted, diff --git a/packages/backend/server/src/fundamentals/event/def.ts b/packages/backend/server/src/fundamentals/event/def.ts index 018516562c..3e4641e97c 100644 --- a/packages/backend/server/src/fundamentals/event/def.ts +++ b/packages/backend/server/src/fundamentals/event/def.ts @@ -22,6 +22,7 @@ export interface DocEvents { } export interface UserEvents { + updated: Payload>; deleted: Payload; }