From 5adfb3edacebf2c04f3f28085393b13f2b046ee4 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Tue, 9 Apr 2024 16:26:50 +0800 Subject: [PATCH] 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. --- .../database-webhook-router.service.ts | 18 +++ .../delete-personal-account.service.ts | 60 +-------- packages/features/team-accounts/package.json | 2 +- .../services/account-invitations.service.ts | 115 ++++++++++++------ .../account-invitations-webhook.service.ts | 0 .../webhooks/account-webhooks.service.ts | 81 ++++++++++++ .../src/server/services/webhooks/index.ts | 2 + supabase/seed.sql | 11 ++ 8 files changed, 193 insertions(+), 96 deletions(-) rename packages/features/team-accounts/src/server/services/{ => webhooks}/account-invitations-webhook.service.ts (100%) create mode 100644 packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts create mode 100644 packages/features/team-accounts/src/server/services/webhooks/index.ts diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts index 66c8e7be5..a0a210bbe 100644 --- a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts +++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts @@ -22,6 +22,12 @@ export class DatabaseWebhookRouterService { return this.handleSubscriptionsWebhook(payload); } + case 'accounts': { + const payload = body as RecordChange; + + return this.handleAccountsWebhook(payload); + } + default: { const logger = await getLogger(); @@ -55,4 +61,16 @@ export class DatabaseWebhookRouterService { 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); + } + } } diff --git a/packages/features/accounts/src/server/services/delete-personal-account.service.ts b/packages/features/accounts/src/server/services/delete-personal-account.service.ts index af0ad02e3..c14fc53e5 100644 --- a/packages/features/accounts/src/server/services/delete-personal-account.service.ts +++ b/packages/features/accounts/src/server/services/delete-personal-account.service.ts @@ -57,64 +57,6 @@ export class DeletePersonalAccountService { throw new Error('Error deleting user'); } - // Send account deletion email - 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, - }); + logger.info({ name: this.namespace, userId }, 'User deleted successfully'); } } diff --git a/packages/features/team-accounts/package.json b/packages/features/team-accounts/package.json index 0e1e9b5f7..10e1da4e6 100644 --- a/packages/features/team-accounts/package.json +++ b/packages/features/team-accounts/package.json @@ -10,7 +10,7 @@ }, "exports": { "./components": "./src/components/index.ts", - "./webhooks": "./src/server/services/account-invitations-webhook.service.ts" + "./webhooks": "./src/server/services/webhooks/index.ts" }, "devDependencies": { "@hookform/resolvers": "^3.3.4", diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index e21bb874e..618699200 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -16,13 +16,20 @@ export class AccountInvitationsService { constructor(private readonly client: SupabaseClient) {} + /** + * @name deleteInvitation + * @description Removes an invitation from the database. + * @param params + */ async deleteInvitation(params: z.infer) { const logger = await getLogger(); - logger.info('Removing invitation', { + const ctx = { name: this.namespace, ...params, - }); + }; + + logger.info(ctx, 'Removing invitation'); const { data, error } = await this.client .from('invitations') @@ -32,13 +39,12 @@ export class AccountInvitationsService { }); if (error) { + logger.error(ctx, `Failed to remove invitation`); + throw error; } - logger.info('Invitation successfully removed', { - ...params, - name: this.namespace, - }); + logger.info(ctx, 'Invitation successfully removed'); return data; } @@ -46,10 +52,12 @@ export class AccountInvitationsService { async updateInvitation(params: z.infer) { const logger = await getLogger(); - logger.info('Updating invitation', { - ...params, + const ctx = { name: this.namespace, - }); + ...params, + }; + + logger.info(ctx, 'Updating invitation...'); const { data, error } = await this.client .from('invitations') @@ -61,17 +69,28 @@ export class AccountInvitationsService { }); if (error) { + logger.error( + { + ...ctx, + error, + }, + 'Failed to update invitation', + ); + throw error; } - logger.info('Invitation successfully updated', { - ...params, - name: this.namespace, - }); + logger.info(ctx, 'Invitation successfully updated'); return data; } + /** + * @name sendInvitations + * @description Sends invitations to join a team. + * @param accountSlug + * @param invitations + */ async sendInvitations({ accountSlug, invitations, @@ -81,14 +100,12 @@ export class AccountInvitationsService { }) { const logger = await getLogger(); - logger.info( - { - account: accountSlug, - invitations, - name: this.namespace, - }, - 'Storing invitations', - ); + const ctx = { + accountSlug, + name: this.namespace, + }; + + logger.info(ctx, 'Storing invitations...'); const accountResponse = await this.client .from('accounts') @@ -98,10 +115,7 @@ export class AccountInvitationsService { if (!accountResponse.data) { logger.error( - { - accountSlug, - name: this.namespace, - }, + ctx, 'Account not found in database. Cannot send invitations.', ); @@ -116,9 +130,8 @@ export class AccountInvitationsService { if (response.error) { logger.error( { - accountSlug, + ...ctx, error: response.error, - name: this.namespace, }, `Failed to add invitations to account ${accountSlug}`, ); @@ -132,16 +145,16 @@ export class AccountInvitationsService { logger.info( { - account: accountSlug, + ...ctx, count: responseInvitations.length, - name: this.namespace, }, 'Invitations added to account', ); } /** - * Accepts an invitation to join a team. + * @name acceptInvitationToTeam + * @description Accepts an invitation to join a team. */ async acceptInvitationToTeam( adminClient: SupabaseClient, @@ -150,25 +163,50 @@ export class AccountInvitationsService { 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', { token: params.inviteToken, user_id: params.userId, }); if (error) { + logger.error( + { + ...ctx, + error, + }, + 'Failed to accept invitation to team', + ); + throw error; } + logger.info(ctx, 'Successfully accepted invitation to team'); + 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) { const logger = await getLogger(); - logger.info('Renewing invitation', { + const ctx = { invitationId, name: this.namespace, - }); + }; + + logger.info(ctx, 'Renewing invitation...'); const sevenDaysFromNow = formatISO(addDays(new Date(), 7)); @@ -182,13 +220,18 @@ export class AccountInvitationsService { }); if (error) { + logger.error( + { + ...ctx, + error, + }, + 'Failed to renew invitation', + ); + throw error; } - logger.info('Invitation successfully renewed', { - invitationId, - name: this.namespace, - }); + logger.info(ctx, 'Invitation successfully renewed'); return data; } diff --git a/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts b/packages/features/team-accounts/src/server/services/webhooks/account-invitations-webhook.service.ts similarity index 100% rename from packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts rename to packages/features/team-accounts/src/server/services/webhooks/account-invitations-webhook.service.ts diff --git a/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts b/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts new file mode 100644 index 000000000..95678dfb7 --- /dev/null +++ b/packages/features/team-accounts/src/server/services/webhooks/account-webhooks.service.ts @@ -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, + }); + } +} diff --git a/packages/features/team-accounts/src/server/services/webhooks/index.ts b/packages/features/team-accounts/src/server/services/webhooks/index.ts new file mode 100644 index 000000000..60b22550e --- /dev/null +++ b/packages/features/team-accounts/src/server/services/webhooks/index.ts @@ -0,0 +1,2 @@ +export * from './account-webhooks.service'; +export * from './account-invitations-webhook.service'; diff --git a/supabase/seed.sql b/supabase/seed.sql index 4030b01d4..526c37c9a 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -2,6 +2,17 @@ -- 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. +-- 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 create trigger "accounts_memberships_insert" after insert on "public"."accounts_memberships" for each row