feat: add customer event (#7029)

This commit is contained in:
darkskygit 2024-05-24 08:40:33 +00:00
parent 937b8bf166
commit 0302a85585
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
13 changed files with 137 additions and 50 deletions

View File

@ -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`,

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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'

View File

@ -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 }}

View File

@ -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',

View File

@ -377,7 +377,10 @@ export class AuthService implements OnApplicationBootstrap {
});
}
async changePassword(id: string, newPassword: string): Promise<User> {
async changePassword(
id: string,
newPassword: string
): Promise<Omit<User, 'password'>> {
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<User> {
async changeEmail(
id: string,
newEmail: string
): Promise<Omit<User, 'password'>> {
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) {

View File

@ -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 };
}

View File

@ -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);
}
}
}
}

View File

@ -15,6 +15,7 @@ import { RevertCommand, RunCommand } from './commands/run';
},
metrics: {
enabled: false,
customerIo: {},
},
}),
BusinessAppModule,

View File

@ -340,6 +340,9 @@ export interface AFFiNEConfig {
metrics: {
enabled: boolean;
customerIo: {
token: string;
};
};
telemetry: {

View File

@ -188,6 +188,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
},
metrics: {
enabled: false,
customerIo: {
token: '',
},
},
telemetry: {
enabled: isSelfhosted,

View File

@ -22,6 +22,7 @@ export interface DocEvents {
}
export interface UserEvents {
updated: Payload<Omit<User, 'password'>>;
deleted: Payload<User>;
}