feat: add customer event (#7029)
This commit is contained in:
3
.github/actions/deploy/deploy.mjs
vendored
3
.github/actions/deploy/deploy.mjs
vendored
@@ -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`,
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
9
.github/helm/affine/charts/graphql/templates/metrics-secret.yaml
vendored
Normal file
9
.github/helm/affine/charts/graphql/templates/metrics-secret.yaml
vendored
Normal 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 }}
|
||||||
@@ -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'
|
||||||
|
|||||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { RevertCommand, RunCommand } from './commands/run';
|
|||||||
},
|
},
|
||||||
metrics: {
|
metrics: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
customerIo: {},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BusinessAppModule,
|
BusinessAppModule,
|
||||||
|
|||||||
@@ -340,6 +340,9 @@ export interface AFFiNEConfig {
|
|||||||
|
|
||||||
metrics: {
|
metrics: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
customerIo: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
telemetry: {
|
telemetry: {
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
},
|
},
|
||||||
metrics: {
|
metrics: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
customerIo: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
telemetry: {
|
telemetry: {
|
||||||
enabled: isSelfhosted,
|
enabled: isSelfhosted,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface DocEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserEvents {
|
export interface UserEvents {
|
||||||
|
updated: Payload<Omit<User, 'password'>>;
|
||||||
deleted: Payload<User>;
|
deleted: Payload<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user