Refactor account deletion process and improve invitation functionalities

The account deletion process has been refactored to send account deletion emails from the AccountWebhooksService instead of from the deletePersonalAccount service. This has resulted in the addition of the AccountWebhooksService and modification of the seeds.sql file to trigger a webhook after account deletion. Along with this, the account invitation functionalities within the accountInvitations service have been considerably enhanced, making it much clearer and easier to use.
This commit is contained in:
giancarlo
2024-04-09 16:26:50 +08:00
parent 1a3c27326b
commit 5adfb3edac
8 changed files with 193 additions and 96 deletions

View File

@@ -22,6 +22,12 @@ export class DatabaseWebhookRouterService {
return this.handleSubscriptionsWebhook(payload); return this.handleSubscriptionsWebhook(payload);
} }
case 'accounts': {
const payload = body as RecordChange<typeof body.table>;
return this.handleAccountsWebhook(payload);
}
default: { default: {
const logger = await getLogger(); const logger = await getLogger();
@@ -55,4 +61,16 @@ export class DatabaseWebhookRouterService {
return service.handleSubscriptionDeletedWebhook(body.old_record); return service.handleSubscriptionDeletedWebhook(body.old_record);
} }
} }
private async handleAccountsWebhook(body: RecordChange<'accounts'>) {
const { AccountWebhooksService } = await import(
'@kit/team-accounts/webhooks'
);
const service = new AccountWebhooksService();
if (body.type === 'DELETE' && body.old_record) {
return service.handleAccountDeletedWebhook(body.old_record);
}
}
} }

View File

@@ -57,64 +57,6 @@ export class DeletePersonalAccountService {
throw new Error('Error deleting user'); throw new Error('Error deleting user');
} }
// Send account deletion email logger.info({ name: this.namespace, userId }, 'User deleted successfully');
if (params.userEmail) {
try {
logger.info(
{
name: this.namespace,
userId,
},
`Sending account deletion email...`,
);
await this.sendAccountDeletionEmail({
fromEmail: params.emailSettings.fromEmail,
productName: params.emailSettings.productName,
userDisplayName: params.userEmail,
userEmail: params.userEmail,
});
logger.info(
{
name: this.namespace,
userId,
},
`Account deletion email sent`,
);
} catch (error) {
logger.error(
{
name: this.namespace,
userId,
error,
},
`Error sending account deletion email`,
);
}
}
}
private async sendAccountDeletionEmail(params: {
fromEmail: string;
userEmail: string;
userDisplayName: string;
productName: string;
}) {
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const html = renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
return mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject: 'Account Deletion Request',
html,
});
} }
} }

View File

@@ -10,7 +10,7 @@
}, },
"exports": { "exports": {
"./components": "./src/components/index.ts", "./components": "./src/components/index.ts",
"./webhooks": "./src/server/services/account-invitations-webhook.service.ts" "./webhooks": "./src/server/services/webhooks/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",

View File

@@ -16,13 +16,20 @@ export class AccountInvitationsService {
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name deleteInvitation
* @description Removes an invitation from the database.
* @param params
*/
async deleteInvitation(params: z.infer<typeof DeleteInvitationSchema>) { async deleteInvitation(params: z.infer<typeof DeleteInvitationSchema>) {
const logger = await getLogger(); const logger = await getLogger();
logger.info('Removing invitation', { const ctx = {
name: this.namespace, name: this.namespace,
...params, ...params,
}); };
logger.info(ctx, 'Removing invitation');
const { data, error } = await this.client const { data, error } = await this.client
.from('invitations') .from('invitations')
@@ -32,13 +39,12 @@ export class AccountInvitationsService {
}); });
if (error) { if (error) {
logger.error(ctx, `Failed to remove invitation`);
throw error; throw error;
} }
logger.info('Invitation successfully removed', { logger.info(ctx, 'Invitation successfully removed');
...params,
name: this.namespace,
});
return data; return data;
} }
@@ -46,10 +52,12 @@ export class AccountInvitationsService {
async updateInvitation(params: z.infer<typeof UpdateInvitationSchema>) { async updateInvitation(params: z.infer<typeof UpdateInvitationSchema>) {
const logger = await getLogger(); const logger = await getLogger();
logger.info('Updating invitation', { const ctx = {
...params,
name: this.namespace, name: this.namespace,
}); ...params,
};
logger.info(ctx, 'Updating invitation...');
const { data, error } = await this.client const { data, error } = await this.client
.from('invitations') .from('invitations')
@@ -61,17 +69,28 @@ export class AccountInvitationsService {
}); });
if (error) { if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to update invitation',
);
throw error; throw error;
} }
logger.info('Invitation successfully updated', { logger.info(ctx, 'Invitation successfully updated');
...params,
name: this.namespace,
});
return data; return data;
} }
/**
* @name sendInvitations
* @description Sends invitations to join a team.
* @param accountSlug
* @param invitations
*/
async sendInvitations({ async sendInvitations({
accountSlug, accountSlug,
invitations, invitations,
@@ -81,14 +100,12 @@ export class AccountInvitationsService {
}) { }) {
const logger = await getLogger(); const logger = await getLogger();
logger.info( const ctx = {
{ accountSlug,
account: accountSlug, name: this.namespace,
invitations, };
name: this.namespace,
}, logger.info(ctx, 'Storing invitations...');
'Storing invitations',
);
const accountResponse = await this.client const accountResponse = await this.client
.from('accounts') .from('accounts')
@@ -98,10 +115,7 @@ export class AccountInvitationsService {
if (!accountResponse.data) { if (!accountResponse.data) {
logger.error( logger.error(
{ ctx,
accountSlug,
name: this.namespace,
},
'Account not found in database. Cannot send invitations.', 'Account not found in database. Cannot send invitations.',
); );
@@ -116,9 +130,8 @@ export class AccountInvitationsService {
if (response.error) { if (response.error) {
logger.error( logger.error(
{ {
accountSlug, ...ctx,
error: response.error, error: response.error,
name: this.namespace,
}, },
`Failed to add invitations to account ${accountSlug}`, `Failed to add invitations to account ${accountSlug}`,
); );
@@ -132,16 +145,16 @@ export class AccountInvitationsService {
logger.info( logger.info(
{ {
account: accountSlug, ...ctx,
count: responseInvitations.length, count: responseInvitations.length,
name: this.namespace,
}, },
'Invitations added to account', 'Invitations added to account',
); );
} }
/** /**
* Accepts an invitation to join a team. * @name acceptInvitationToTeam
* @description Accepts an invitation to join a team.
*/ */
async acceptInvitationToTeam( async acceptInvitationToTeam(
adminClient: SupabaseClient<Database>, adminClient: SupabaseClient<Database>,
@@ -150,25 +163,50 @@ export class AccountInvitationsService {
inviteToken: string; inviteToken: string;
}, },
) { ) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
};
logger.info(ctx, 'Accepting invitation to team');
const { error, data } = await adminClient.rpc('accept_invitation', { const { error, data } = await adminClient.rpc('accept_invitation', {
token: params.inviteToken, token: params.inviteToken,
user_id: params.userId, user_id: params.userId,
}); });
if (error) { if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to accept invitation to team',
);
throw error; throw error;
} }
logger.info(ctx, 'Successfully accepted invitation to team');
return data; return data;
} }
/**
* @name renewInvitation
* @description Renews an invitation to join a team by extending the expiration date by 7 days.
* @param invitationId
*/
async renewInvitation(invitationId: number) { async renewInvitation(invitationId: number) {
const logger = await getLogger(); const logger = await getLogger();
logger.info('Renewing invitation', { const ctx = {
invitationId, invitationId,
name: this.namespace, name: this.namespace,
}); };
logger.info(ctx, 'Renewing invitation...');
const sevenDaysFromNow = formatISO(addDays(new Date(), 7)); const sevenDaysFromNow = formatISO(addDays(new Date(), 7));
@@ -182,13 +220,18 @@ export class AccountInvitationsService {
}); });
if (error) { if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to renew invitation',
);
throw error; throw error;
} }
logger.info('Invitation successfully renewed', { logger.info(ctx, 'Invitation successfully renewed');
invitationId,
name: this.namespace,
});
return data; return data;
} }

View File

@@ -0,0 +1,81 @@
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
type Account = Database['public']['Tables']['accounts']['Row'];
export class AccountWebhooksService {
private readonly namespace = 'accounts.webhooks';
async handleAccountDeletedWebhook(account: Account) {
const logger = await getLogger();
const ctx = {
accountId: account.id,
namespace: this.namespace,
};
logger.info(ctx, 'Received account deleted webhook. Processing...');
if (account.is_personal_account) {
logger.info(ctx, `Account is personal. We send an email to the user.`);
await this.sendDeleteAccountEmail(account);
}
}
private async sendDeleteAccountEmail(account: Account) {
const userEmail = account.email;
const userDisplayName = account.name ?? userEmail;
const emailSettings = this.getEmailSettings();
if (userEmail) {
await this.sendAccountDeletionEmail({
fromEmail: emailSettings.fromEmail,
productName: emailSettings.productName,
userDisplayName,
userEmail,
});
}
}
private async sendAccountDeletionEmail(params: {
fromEmail: string;
userEmail: string;
userDisplayName: string;
productName: string;
}) {
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const html = renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
return mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject: 'Account Deletion Request',
html,
});
}
private getEmailSettings() {
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
const fromEmail = process.env.EMAIL_SENDER;
return z
.object({
productName: z.string(),
fromEmail: z.string().email(),
})
.parse({
productName,
fromEmail,
});
}
}

View File

@@ -0,0 +1,2 @@
export * from './account-webhooks.service';
export * from './account-invitations-webhook.service';

View File

@@ -2,6 +2,17 @@
-- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so). -- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so).
-- We don't do it because you'll need to manually add your webhook URL and secret key. -- We don't do it because you'll need to manually add your webhook URL and secret key.
-- this webhook will be triggered after deleting an account
create trigger "accounts_teardown" after delete
on "public"."accounts" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
'1000'
);
-- this webhook will be triggered after every insert on the accounts_memberships table -- this webhook will be triggered after every insert on the accounts_memberships table
create trigger "accounts_memberships_insert" after insert create trigger "accounts_memberships_insert" after insert
on "public"."accounts_memberships" for each row on "public"."accounts_memberships" for each row