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:
@@ -22,6 +22,12 @@ export class DatabaseWebhookRouterService {
|
||||
return this.handleSubscriptionsWebhook(payload);
|
||||
}
|
||||
|
||||
case 'accounts': {
|
||||
const payload = body as RecordChange<typeof body.table>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,13 +16,20 @@ export class AccountInvitationsService {
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name deleteInvitation
|
||||
* @description Removes an invitation from the database.
|
||||
* @param params
|
||||
*/
|
||||
async deleteInvitation(params: z.infer<typeof DeleteInvitationSchema>) {
|
||||
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<typeof UpdateInvitationSchema>) {
|
||||
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<Database>,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './account-webhooks.service';
|
||||
export * from './account-invitations-webhook.service';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user