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