feat: emit user.deleted event on user deletion (#3228)
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled

This commit is contained in:
Daniel Salazar
2026-06-08 10:30:24 -07:00
committed by GitHub
parent 1335b46404
commit def7290c48
3 changed files with 76 additions and 0 deletions
+9
View File
@@ -160,6 +160,15 @@ export type EventMap = {
new_username: string;
};
'user.email-changed': { user_id: number; new_email: string };
// Fired after an account is torn down (self-serve, admin, or temp-user
// logout cleanup). Listeners purge external state tied to the account —
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
// The row is already gone by emit time, so identifiers ride the payload.
'user.delete': {
user_id: number;
user_uuid?: string;
stripe_customer_id?: string | null;
};
// ---- Filesystem ----
'fs.copy.node': {
@@ -74,6 +74,7 @@ type SignupValidateOverride = (data: {
let signupValidateOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
const heardUserDelete: Array<Record<string, unknown>> = [];
const installSharedListeners = () => {
eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => {
@@ -86,6 +87,9 @@ const installSharedListeners = () => {
eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
eventClient.on('user.delete', (_k: unknown, data: unknown) => {
heardUserDelete.push(data as Record<string, unknown>);
});
};
const withSignupValidateOverride = async <T>(
@@ -2682,6 +2686,35 @@ describe('AuthController.handleDeleteOwnUser', () => {
});
expect(after).toBeFalsy();
});
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
// captures it (it's how the marketplace extension cancels the sub).
try {
await server.clients.db.write(
'ALTER TABLE user ADD COLUMN stripe_customer_id TEXT',
[],
);
} catch {
/* already exists */
}
const { user, actor } = await makeUserAndActor();
await server.clients.db.write(
'UPDATE user SET stripe_customer_id = ? WHERE id = ?',
['cus_delete_test', user.id],
);
heardUserDelete.length = 0;
await controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes());
const evt = heardUserDelete.find((e) => e.user_id === user.id);
expect(evt).toMatchObject({
user_id: user.id,
user_uuid: user.uuid,
stripe_customer_id: 'cus_delete_test',
});
});
});
// ── Additional branch coverage ─────────────────────────────────────
@@ -2820,6 +2820,22 @@ export class AuthController extends PuterController {
// -- Private helpers ----------------------------------------------
async #cascadeDeleteUser(userId: number): Promise<void> {
// Capture the identifiers downstream teardown needs before the row is
// gone — the marketplace extension cancels the user's Stripe
// subscriptions off `user.delete`, keyed by uuid / customer id.
let userUuid: string | undefined;
let stripeCustomerId: string | null = null;
try {
const rows = (await this.clients.db.read(
'SELECT `uuid`, `stripe_customer_id` FROM `user` WHERE `id` = ?',
[userId],
)) as Array<{ uuid?: string; stripe_customer_id?: string | null }>;
userUuid = rows[0]?.uuid;
stripeCustomerId = rows[0]?.stripe_customer_id ?? null;
} catch (e) {
console.warn('[cascade-delete-user] identifier lookup failed:', e);
}
try {
await this.services.fs.removeAllForUser(userId);
} catch (e) {
@@ -2837,6 +2853,24 @@ export class AuthController extends PuterController {
userId,
]);
await this.stores.user.invalidateById(userId);
// Fire-and-forget: let listeners purge external state tied to the
// account (Stripe subscriptions are cancelled immediately, without
// proration). Emitted after the row delete — listeners key off the
// payload, not the DB row.
try {
this.clients.event?.emit(
'user.delete',
{
user_id: userId,
user_uuid: userUuid,
stripe_customer_id: stripeCustomerId,
},
{},
);
} catch {
// ignore — event emission shouldn't block deletion
}
}
async #generateRandomUsername(): Promise<string> {