feat: add customer event (#7029)

This commit is contained in:
darkskygit
2024-05-24 08:40:33 +00:00
parent 937b8bf166
commit 0302a85585
13 changed files with 137 additions and 50 deletions

View File

@@ -14,6 +14,7 @@ const {
R2_ACCESS_KEY_ID, R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY, R2_SECRET_ACCESS_KEY,
CAPTCHA_TURNSTILE_SECRET, CAPTCHA_TURNSTILE_SECRET,
METRICS_CUSTOMER_IO_TOKEN,
COPILOT_OPENAI_API_KEY, COPILOT_OPENAI_API_KEY,
COPILOT_FAL_API_KEY, COPILOT_FAL_API_KEY,
COPILOT_UNSPLASH_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.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`,
`--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`, `--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`,
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_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.experimental.enableJwstCodec=${namespace === 'dev'}`,
`--set graphql.app.features.earlyAccessPreview=false`, `--set graphql.app.features.earlyAccessPreview=false`,
`--set graphql.app.features.syncClientVersionCheck=true`, `--set graphql.app.features.syncClientVersionCheck=true`,

View File

@@ -191,6 +191,13 @@ spec:
name: "{{ .Values.app.oauth.github.secretName }}" name: "{{ .Values.app.oauth.github.secretName }}"
key: clientSecret key: clientSecret
{{ end }} {{ end }}
{{ if .Values.app.metrics.enabled }}
- name: METRICS_CUSTOMER_IO_TOKEN
valueFrom:
secretKeyRef:
name: "{{ .Values.app.metrics.secretName }}"
key: customerIoSecret
{{ end }}
ports: ports:
- name: http - name: http
containerPort: {{ .Values.service.port }} 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: doc:
mergeInterval: "3000" mergeInterval: "3000"
captcha: captcha:
enable: false enabled: false
secretName: captcha secretName: captcha
turnstile: turnstile:
secret: '' secret: ''
copilot: copilot:
enable: false enabled: false
secretName: copilot secretName: copilot
openai: openai:
key: '' key: ''
@@ -54,6 +54,11 @@ app:
user: '' user: ''
password: '' password: ''
sender: 'noreply@toeverything.info' sender: 'noreply@toeverything.info'
metrics:
enabled: false
secretName: 'metrics'
customerIo:
token: ''
payment: payment:
stripe: stripe:
secretName: 'stripe' secretName: 'stripe'

View File

@@ -137,6 +137,7 @@ jobs:
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }} COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }} COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }}
COPILOT_UNSPLASH_API_KEY: ${{ secrets.COPILOT_UNSPLASH_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_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
MAILER_USER: ${{ secrets.OAUTH_EMAIL_LOGIN }} MAILER_USER: ${{ secrets.OAUTH_EMAIL_LOGIN }}
MAILER_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }} MAILER_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}

View File

@@ -26,6 +26,7 @@ AFFiNE.ENV_MAP = {
MAILER_SECURE: ['mailer.secure', 'boolean'], MAILER_SECURE: ['mailer.secure', 'boolean'],
THROTTLE_TTL: ['rateLimiter.ttl', 'int'], THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'], THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'],
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey', COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey', COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey', 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); const user = await this.user.findUserById(id);
if (!user) { if (!user) {
@@ -386,46 +389,31 @@ export class AuthService implements OnApplicationBootstrap {
const hashedPassword = await this.crypto.encryptPassword(newPassword); const hashedPassword = await this.crypto.encryptPassword(newPassword);
return this.db.user.update({ return this.user.updateUser(user.id, { password: hashedPassword });
where: {
id: user.id,
},
data: {
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); const user = await this.user.findUserById(id);
if (!user) { if (!user) {
throw new BadRequestException('Invalid email'); throw new BadRequestException('Invalid email');
} }
return this.db.user.update({ return this.user.updateUser(id, {
where: { email: newEmail,
id, emailVerifiedAt: new Date(),
},
data: {
email: newEmail,
emailVerifiedAt: new Date(),
},
}); });
} }
async setEmailVerified(id: string) { async setEmailVerified(id: string) {
return await this.db.user.update({ return await this.user.updateUser(
where: { id,
id, { emailVerifiedAt: new Date() },
}, { emailVerifiedAt: true }
data: { );
emailVerifiedAt: new Date(),
},
select: {
emailVerifiedAt: true,
},
});
} }
async sendChangePasswordEmail(email: string, callbackUrl: string) { async sendChangePasswordEmail(email: string, callbackUrl: string) {

View File

@@ -117,7 +117,7 @@ export class UserResolver {
throw new BadRequestException(`User not found`); throw new BadRequestException(`User not found`);
} }
const link = await this.storage.put( const avatarUrl = await this.storage.put(
`${user.id}-avatar`, `${user.id}-avatar`,
avatar.createReadStream(), avatar.createReadStream(),
{ {
@@ -125,12 +125,7 @@ export class UserResolver {
} }
); );
return this.prisma.user.update({ return this.users.updateUser(user.id, { avatarUrl });
where: { id: user.id },
data: {
avatarUrl: link,
},
});
} }
@Mutation(() => UserType, { @Mutation(() => UserType, {
@@ -146,12 +141,7 @@ export class UserResolver {
return user; return user;
} }
return sessionUser( return sessionUser(await this.users.updateUser(user.id, input));
await this.prisma.user.update({
where: { id: user.id },
data: input,
})
);
} }
@Mutation(() => RemoveAvatar, { @Mutation(() => RemoveAvatar, {
@@ -162,10 +152,7 @@ export class UserResolver {
if (!user) { if (!user) {
throw new BadRequestException(`User not found`); throw new BadRequestException(`User not found`);
} }
await this.prisma.user.update({ await this.users.updateUser(user.id, { avatarUrl: null });
where: { id: user.id },
data: { avatarUrl: null },
});
return { success: true }; 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 { Prisma, PrismaClient } from '@prisma/client';
import {
Config,
EventEmitter,
type EventPayload,
OnEvent,
} from '../../fundamentals';
import { Quota_FreePlanV1_1 } from '../quota/schema'; import { Quota_FreePlanV1_1 } from '../quota/schema';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
defaultUserSelect = { defaultUserSelect = {
id: true, id: true,
name: true, name: true,
@@ -12,9 +20,14 @@ export class UserService {
emailVerifiedAt: true, emailVerifiedAt: true,
avatarUrl: true, avatarUrl: true,
registered: true, registered: true,
createdAt: true,
} satisfies Prisma.UserSelect; } satisfies Prisma.UserSelect;
constructor(private readonly prisma: PrismaClient) {} constructor(
private readonly config: Config,
private readonly prisma: PrismaClient,
private readonly emitter: EventEmitter
) {}
get userCreatingData() { get userCreatingData() {
return { 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; return user;
} }
async deleteUser(id: string) { async deleteUser(id: string) {
return this.prisma.user.delete({ where: { id } }); 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: { metrics: {
enabled: false, enabled: false,
customerIo: {},
}, },
}), }),
BusinessAppModule, BusinessAppModule,

View File

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

View File

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

View File

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