B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CreateTeamSchema } from '../../schema/create-team.schema';
|
||||
import { createCreateTeamAccountService } from '../services/create-team-account.service';
|
||||
|
||||
export const createTeamAccountAction = enhanceAction(
|
||||
async ({ name }, user) => {
|
||||
const logger = await getLogger();
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createCreateTeamAccountService(client);
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.create',
|
||||
userId: user.id,
|
||||
accountName: name,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Creating team account...`);
|
||||
|
||||
const { data, error } = await service.createNewOrganizationAccount({
|
||||
name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, `Failed to create team account`);
|
||||
|
||||
return {
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(ctx, `Team account created`);
|
||||
|
||||
const accountHomePath = '/home/' + data.slug;
|
||||
|
||||
redirect(accountHomePath);
|
||||
},
|
||||
{
|
||||
schema: CreateTeamSchema,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { DeleteTeamAccountSchema } from '../../schema/delete-team-account.schema';
|
||||
import { createDeleteTeamAccountService } from '../services/delete-team-account.service';
|
||||
|
||||
const enableTeamAccountDeletion =
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true';
|
||||
|
||||
export const deleteTeamAccountAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
const params = DeleteTeamAccountSchema.parse(
|
||||
Object.fromEntries(formData.entries()),
|
||||
);
|
||||
|
||||
const otpService = createOtpApi(getSupabaseServerClient());
|
||||
|
||||
const otpResult = await otpService.verifyToken({
|
||||
purpose: `delete-team-account-${params.accountId}`,
|
||||
userId: user.id,
|
||||
token: params.otp,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
throw new Error('Invalid OTP code');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.delete',
|
||||
userId: user.id,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
|
||||
if (!enableTeamAccountDeletion) {
|
||||
logger.warn(ctx, `Team account deletion is not enabled`);
|
||||
|
||||
throw new Error('Team account deletion is not enabled');
|
||||
}
|
||||
|
||||
logger.info(ctx, `Deleting team account...`);
|
||||
|
||||
await deleteTeamAccount({
|
||||
accountId: params.accountId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
logger.info(ctx, `Team account request successfully sent`);
|
||||
|
||||
return redirect('/home');
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
|
||||
async function deleteTeamAccount(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createDeleteTeamAccountService();
|
||||
|
||||
// verify that the user has the necessary permissions to delete the team account
|
||||
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
|
||||
|
||||
// delete the team account
|
||||
await service.deleteTeamAccount(client, params);
|
||||
}
|
||||
|
||||
async function assertUserPermissionsToDeleteTeamAccount(
|
||||
client: SupabaseClient<Database>,
|
||||
accountId: string,
|
||||
) {
|
||||
const { data: isOwner, error } = await client
|
||||
.rpc('is_account_owner', {
|
||||
account_id: accountId,
|
||||
})
|
||||
.single();
|
||||
|
||||
if (error || !isOwner) {
|
||||
throw new Error('You do not have permission to delete this account');
|
||||
}
|
||||
|
||||
return isOwner;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema';
|
||||
import { createLeaveTeamAccountService } from '../services/leave-team-account.service';
|
||||
|
||||
export const leaveTeamAccountAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = LeaveTeamAccountSchema.parse(body);
|
||||
|
||||
const service = createLeaveTeamAccountService(
|
||||
getSupabaseServerAdminClient(),
|
||||
);
|
||||
|
||||
await service.leaveTeamAccount({
|
||||
accountId: params.accountId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return redirect('/home');
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema';
|
||||
|
||||
export const updateTeamAccountName = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const { name, path, slug } = params;
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.update',
|
||||
accountName: name,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Updating team name...`);
|
||||
|
||||
const { error, data } = await client
|
||||
.from('accounts')
|
||||
.update({
|
||||
name,
|
||||
slug,
|
||||
})
|
||||
.match({
|
||||
slug,
|
||||
})
|
||||
.select('slug')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, `Failed to update team name`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const newSlug = data.slug;
|
||||
|
||||
logger.info(ctx, `Team name updated`);
|
||||
|
||||
if (newSlug) {
|
||||
const nextPath = path.replace('[account]', newSlug);
|
||||
|
||||
redirect(nextPath);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: UpdateTeamNameSchema,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
||||
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
||||
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||
|
||||
/**
|
||||
* @name createInvitationsAction
|
||||
* @description Creates invitations for inviting members.
|
||||
*/
|
||||
export const createInvitationsAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Create the service
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// send invitations
|
||||
await service.sendInvitations(params);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: InviteMembersSchema.and(
|
||||
z.object({
|
||||
accountSlug: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteInvitationAction
|
||||
* @description Deletes an invitation specified by the invitation ID.
|
||||
*/
|
||||
export const deleteInvitationAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// Delete the invitation
|
||||
await service.deleteInvitation(data);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: DeleteInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name updateInvitationAction
|
||||
* @description Updates an invitation.
|
||||
*/
|
||||
export const updateInvitationAction = enhanceAction(
|
||||
async (invitation) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
await service.updateInvitation(invitation);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: UpdateInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name acceptInvitationAction
|
||||
* @description Accepts an invitation to join a team.
|
||||
*/
|
||||
export const acceptInvitationAction = enhanceAction(
|
||||
async (data: FormData, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { inviteToken, nextPath } = AcceptInvitationSchema.parse(
|
||||
Object.fromEntries(data),
|
||||
);
|
||||
|
||||
// create the services
|
||||
const perSeatBillingService = createAccountPerSeatBillingService(client);
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// use admin client to accept invitation
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// Accept the invitation
|
||||
const accountId = await service.acceptInvitationToTeam(adminClient, {
|
||||
inviteToken,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// If the account ID is not present, throw an error
|
||||
if (!accountId) {
|
||||
throw new Error('Failed to accept invitation');
|
||||
}
|
||||
|
||||
// Increase the seats for the account
|
||||
await perSeatBillingService.increaseSeats(accountId);
|
||||
|
||||
return redirect(nextPath);
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name renewInvitationAction
|
||||
* @description Renews an invitation.
|
||||
*/
|
||||
export const renewInvitationAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const { invitationId } = RenewInvitationSchema.parse(params);
|
||||
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// Renew the invitation
|
||||
await service.renewInvitation(invitationId);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: RenewInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
function revalidateMemberPage() {
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { RemoveMemberSchema } from '../../schema/remove-member.schema';
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
import { UpdateMemberRoleSchema } from '../../schema/update-member-role.schema';
|
||||
import { createAccountMembersService } from '../services/account-members.service';
|
||||
|
||||
/**
|
||||
* @name removeMemberFromAccountAction
|
||||
* @description Removes a member from an account.
|
||||
*/
|
||||
export const removeMemberFromAccountAction = enhanceAction(
|
||||
async ({ accountId, userId }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountMembersService(client);
|
||||
|
||||
await service.removeMemberFromAccount({
|
||||
accountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: RemoveMemberSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name updateMemberRoleAction
|
||||
* @description Updates the role of a member in an account.
|
||||
*/
|
||||
export const updateMemberRoleAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountMembersService(client);
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// update the role of the member
|
||||
await service.updateMemberRole(data, adminClient);
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: UpdateMemberRoleSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name transferOwnershipAction
|
||||
* @description Transfers the ownership of an account to another member.
|
||||
* Requires OTP verification for security.
|
||||
*/
|
||||
export const transferOwnershipAction = enhanceAction(
|
||||
async (data, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'teams.transferOwnership',
|
||||
userId: user.id,
|
||||
accountId: data.accountId,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Processing team ownership transfer request...');
|
||||
|
||||
// assert that the user is the owner of the account
|
||||
const { data: isOwner, error } = await client.rpc('is_account_owner', {
|
||||
account_id: data.accountId,
|
||||
});
|
||||
|
||||
if (error || !isOwner) {
|
||||
logger.error(ctx, 'User is not the owner of this account');
|
||||
|
||||
throw new Error(
|
||||
`You must be the owner of the account to transfer ownership`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the OTP
|
||||
const otpApi = createOtpApi(client);
|
||||
|
||||
const otpResult = await otpApi.verifyToken({
|
||||
token: data.otp,
|
||||
userId: user.id,
|
||||
purpose: `transfer-team-ownership-${data.accountId}`,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
logger.error(ctx, 'Invalid OTP provided');
|
||||
throw new Error('Invalid OTP');
|
||||
}
|
||||
|
||||
// validate the user ID matches the nonce's user ID
|
||||
if (otpResult.user_id !== user.id) {
|
||||
logger.error(
|
||||
ctx,
|
||||
`This token was meant to be used by a different user. Exiting.`,
|
||||
);
|
||||
|
||||
throw new Error('Nonce mismatch');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
'OTP verification successful. Proceeding with ownership transfer...',
|
||||
);
|
||||
|
||||
const service = createAccountMembersService(client);
|
||||
|
||||
// at this point, the user is authenticated, is the owner of the account, and has verified via OTP
|
||||
// so we proceed with the transfer of ownership with admin privileges
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// transfer the ownership of the account
|
||||
await service.transferOwnership(data, adminClient);
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
logger.info(ctx, 'Team ownership transferred successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: TransferOwnershipConfirmationSchema,
|
||||
},
|
||||
);
|
||||
236
packages/features/team-accounts/src/server/api.ts
Normal file
236
packages/features/team-accounts/src/server/api.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* Class representing an API for interacting with team accounts.
|
||||
* @constructor
|
||||
* @param {SupabaseClient<Database>} client - The Supabase client instance.
|
||||
*/
|
||||
export class TeamAccountsApi {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name getTeamAccount
|
||||
* @description Get the account data for the given slug.
|
||||
* @param slug
|
||||
*/
|
||||
async getTeamAccount(slug: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTeamAccountById
|
||||
* @description Check if the user is already in the account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getTeamAccountById(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts')
|
||||
.select('*')
|
||||
.eq('id', accountId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getSubscription
|
||||
* @description Get the subscription data for the account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getSubscription(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('subscriptions')
|
||||
.select('*, items: subscription_items !inner (*)')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the orders data for the given account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getOrder(accountId: string) {
|
||||
const response = await this.client
|
||||
.from('orders')
|
||||
.select('*, items: order_items !inner (*)')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getAccountWorkspace
|
||||
* @description Get the account workspace data.
|
||||
* @param slug
|
||||
*/
|
||||
async getAccountWorkspace(slug: string) {
|
||||
const accountPromise = this.client.rpc('team_account_workspace', {
|
||||
account_slug: slug,
|
||||
});
|
||||
|
||||
const accountsPromise = this.client.from('user_accounts').select('*');
|
||||
|
||||
const [accountResult, accountsResult] = await Promise.all([
|
||||
accountPromise,
|
||||
accountsPromise,
|
||||
]);
|
||||
|
||||
if (accountResult.error) {
|
||||
return {
|
||||
error: accountResult.error,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (accountsResult.error) {
|
||||
return {
|
||||
error: accountsResult.error,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const accountData = accountResult.data[0];
|
||||
|
||||
if (!accountData) {
|
||||
return {
|
||||
error: new Error('Account data not found'),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
account: accountData,
|
||||
accounts: accountsResult.data,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @name hasPermission
|
||||
* @description Check if the user has permission to manage billing for the account.
|
||||
*/
|
||||
async hasPermission(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
permission: Database['public']['Enums']['app_permissions'];
|
||||
}) {
|
||||
const { data, error } = await this.client.rpc('has_permission', {
|
||||
account_id: params.accountId,
|
||||
user_id: params.userId,
|
||||
permission_name: params.permission,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getMembersCount
|
||||
* @description Get the number of members in the account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getMembersCount(accountId: string) {
|
||||
const { count, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.select('*', {
|
||||
head: true,
|
||||
count: 'exact',
|
||||
})
|
||||
.eq('account_id', accountId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getCustomerId
|
||||
* @description Get the billing customer ID for the given account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getCustomerId(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('billing_customers')
|
||||
.select('customer_id')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data?.customer_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getInvitation
|
||||
* @description Get the invitation data from the invite token.
|
||||
* @param adminClient - The admin client instance. Since the user is not yet part of the account, we need to use an admin client to read the pending membership
|
||||
* @param token - The invitation token.
|
||||
*/
|
||||
async getInvitation(adminClient: SupabaseClient<Database>, token: string) {
|
||||
const { data: invitation, error } = await adminClient
|
||||
.from('invitations')
|
||||
.select<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
picture_url: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
'id, expires_at, account: account_id !inner (id, name, slug, picture_url)',
|
||||
)
|
||||
.eq('invite_token', token)
|
||||
.gte('expires_at', new Date().toISOString())
|
||||
.single();
|
||||
|
||||
if (error ?? !invitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamAccountsApi(client: SupabaseClient<Database>) {
|
||||
return new TeamAccountsApi(client);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { addDays, formatISO } from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
|
||||
export function createAccountInvitationsService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AccountInvitationsService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name AccountInvitationsService
|
||||
* @description Service for managing account invitations.
|
||||
*/
|
||||
class AccountInvitationsService {
|
||||
private readonly namespace = 'invitations';
|
||||
|
||||
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();
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Removing invitation...');
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('invitations')
|
||||
.delete()
|
||||
.match({
|
||||
id: params.invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(ctx, `Failed to remove invitation`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Invitation successfully removed');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name updateInvitation
|
||||
* @param params
|
||||
* @description Updates an invitation in the database.
|
||||
*/
|
||||
async updateInvitation(params: z.infer<typeof UpdateInvitationSchema>) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Updating invitation...');
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('invitations')
|
||||
.update({
|
||||
role: params.role,
|
||||
})
|
||||
.match({
|
||||
id: params.invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to update invitation',
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Invitation successfully updated');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async validateInvitation(
|
||||
invitation: z.infer<typeof InviteMembersSchema>['invitations'][number],
|
||||
accountSlug: string,
|
||||
) {
|
||||
const { data: members, error } = await this.client.rpc(
|
||||
'get_account_members',
|
||||
{
|
||||
account_slug: accountSlug,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isUserAlreadyMember = members.find((member) => {
|
||||
return member.email === invitation.email;
|
||||
});
|
||||
|
||||
if (isUserAlreadyMember) {
|
||||
throw new Error('User already member of the team');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name sendInvitations
|
||||
* @description Sends invitations to join a team.
|
||||
* @param accountSlug
|
||||
* @param invitations
|
||||
*/
|
||||
async sendInvitations({
|
||||
accountSlug,
|
||||
invitations,
|
||||
}: {
|
||||
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
||||
accountSlug: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
accountSlug,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Storing invitations...');
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
invitations.map((invitation) =>
|
||||
this.validateInvitation(invitation, accountSlug),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error: (error as Error).message,
|
||||
},
|
||||
'Error validating invitations',
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const accountResponse = await this.client
|
||||
.from('accounts')
|
||||
.select('name')
|
||||
.eq('slug', accountSlug)
|
||||
.single();
|
||||
|
||||
if (!accountResponse.data) {
|
||||
logger.error(
|
||||
ctx,
|
||||
'Account not found in database. Cannot send invitations.',
|
||||
);
|
||||
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const response = await this.client.rpc('add_invitations_to_account', {
|
||||
invitations,
|
||||
account_slug: accountSlug,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error: response.error,
|
||||
},
|
||||
`Failed to add invitations to account ${accountSlug}`,
|
||||
);
|
||||
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
const responseInvitations = Array.isArray(response.data)
|
||||
? response.data
|
||||
: [response.data];
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
count: responseInvitations.length,
|
||||
},
|
||||
'Invitations added to account',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name acceptInvitationToTeam
|
||||
* @description Accepts an invitation to join a team.
|
||||
*/
|
||||
async acceptInvitationToTeam(
|
||||
adminClient: SupabaseClient<Database>,
|
||||
params: {
|
||||
userId: 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', {
|
||||
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();
|
||||
|
||||
const ctx = {
|
||||
invitationId,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Renewing invitation...');
|
||||
|
||||
const sevenDaysFromNow = formatISO(addDays(new Date(), 7));
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('invitations')
|
||||
.update({
|
||||
expires_at: sevenDaysFromNow,
|
||||
})
|
||||
.match({
|
||||
id: invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to renew invitation',
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Invitation successfully renewed');
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { RemoveMemberSchema } from '../../schema/remove-member.schema';
|
||||
import type { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
import type { UpdateMemberRoleSchema } from '../../schema/update-member-role.schema';
|
||||
import { createAccountPerSeatBillingService } from './account-per-seat-billing.service';
|
||||
|
||||
export function createAccountMembersService(client: SupabaseClient<Database>) {
|
||||
return new AccountMembersService(client);
|
||||
}
|
||||
|
||||
class AccountMembersService {
|
||||
private readonly namespace = 'account-members';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name removeMemberFromAccount
|
||||
* @description Removes a member from an account.
|
||||
* @param params
|
||||
*/
|
||||
async removeMemberFromAccount(params: z.infer<typeof RemoveMemberSchema>) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Removing member from account...`);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.delete()
|
||||
.match({
|
||||
account_id: params.accountId,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to remove member from account`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
`Successfully removed member from account. Verifying seat count...`,
|
||||
);
|
||||
|
||||
const service = createAccountPerSeatBillingService(this.client);
|
||||
|
||||
await service.decreaseSeats(params.accountId);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name updateMemberRole
|
||||
* @description Updates the role of a member in an account.
|
||||
* @param params
|
||||
* @param adminClient
|
||||
*/
|
||||
async updateMemberRole(
|
||||
params: z.infer<typeof UpdateMemberRoleSchema>,
|
||||
adminClient: SupabaseClient<Database>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Validating permissions to update member role...`);
|
||||
|
||||
const { data: canActionAccountMember, error: accountError } =
|
||||
await this.client.rpc('can_action_account_member', {
|
||||
target_user_id: params.userId,
|
||||
target_team_account_id: params.accountId,
|
||||
});
|
||||
|
||||
if (accountError ?? !canActionAccountMember) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
accountError,
|
||||
},
|
||||
`Failed to validate permissions to update member role`,
|
||||
);
|
||||
|
||||
throw new Error(`Failed to validate permissions to update member role`);
|
||||
}
|
||||
|
||||
logger.info(ctx, `Permissions validated. Updating member role...`);
|
||||
|
||||
// we use the Admin client to update the role
|
||||
// since we do not set any RLS policies on the accounts_memberships table
|
||||
// for updating accounts_memberships. Instead, we use the can_action_account_member
|
||||
// RPC to validate permissions to update the role
|
||||
const { data, error } = await adminClient
|
||||
.from('accounts_memberships')
|
||||
.update({
|
||||
account_role: params.role,
|
||||
})
|
||||
.match({
|
||||
account_id: params.accountId,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to update member role`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, `Successfully updated member role`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name transferOwnership
|
||||
* @description Transfers ownership of an account to another user.
|
||||
* @param params
|
||||
* @param adminClient
|
||||
*/
|
||||
async transferOwnership(
|
||||
params: z.infer<typeof TransferOwnershipConfirmationSchema>,
|
||||
adminClient: SupabaseClient<Database>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Transferring ownership of account...`);
|
||||
|
||||
const { data, error } = await adminClient.rpc(
|
||||
'transfer_team_account_ownership',
|
||||
{
|
||||
target_account_id: params.accountId,
|
||||
new_owner_id: params.userId,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{ ...ctx, error },
|
||||
`Failed to transfer ownership of account`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(ctx, `Successfully transferred ownership of account`);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAccountPerSeatBillingService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AccountPerSeatBillingService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name AccountPerSeatBillingService
|
||||
* @description Service for managing per-seat billing for accounts.
|
||||
*/
|
||||
class AccountPerSeatBillingService {
|
||||
private readonly namespace = 'accounts.per-seat-billing';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name getPerSeatSubscriptionItem
|
||||
* @description Retrieves the per-seat subscription item for an account.
|
||||
* @param accountId
|
||||
*/
|
||||
async getPerSeatSubscriptionItem(accountId: string) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { accountId, name: this.namespace };
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
`Retrieving per-seat subscription item for account ${accountId}...`,
|
||||
);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
`
|
||||
provider: billing_provider,
|
||||
id,
|
||||
subscription_items !inner (
|
||||
quantity,
|
||||
id,
|
||||
type
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.eq('subscription_items.type', 'per_seat')
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to get per-seat subscription item for account ${accountId}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data?.subscription_items) {
|
||||
logger.info(
|
||||
ctx,
|
||||
`Account is not subscribed to a per-seat subscription. Exiting...`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
`Per-seat subscription item found for account ${accountId}. Will update...`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name increaseSeats
|
||||
* @description Increases the number of seats for an account.
|
||||
* @param accountId
|
||||
*/
|
||||
async increaseSeats(accountId: string) {
|
||||
const logger = await getLogger();
|
||||
const subscription = await this.getPerSeatSubscriptionItem(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionItems = subscription.subscription_items.filter((item) => {
|
||||
return item.type === 'per_seat';
|
||||
});
|
||||
|
||||
if (!subscriptionItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingGateway = createBillingGatewayService(subscription.provider);
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItems,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Increasing seats for account ${accountId}...`);
|
||||
|
||||
const promises = subscriptionItems.map(async (item) => {
|
||||
try {
|
||||
logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
},
|
||||
`Updating subscription item...`,
|
||||
);
|
||||
|
||||
await billingGateway.updateSubscriptionItem({
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
},
|
||||
`Subscription item updated successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to increase seats for account ${accountId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name decreaseSeats
|
||||
* @description Decreases the number of seats for an account.
|
||||
* @param accountId
|
||||
*/
|
||||
async decreaseSeats(accountId: string) {
|
||||
const logger = await getLogger();
|
||||
const subscription = await this.getPerSeatSubscriptionItem(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionItems = subscription.subscription_items.filter((item) => {
|
||||
return item.type === 'per_seat';
|
||||
});
|
||||
|
||||
if (!subscriptionItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItems,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Decreasing seats for account ${accountId}...`);
|
||||
|
||||
const billingGateway = createBillingGatewayService(subscription.provider);
|
||||
|
||||
const promises = subscriptionItems.map(async (item) => {
|
||||
try {
|
||||
logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
},
|
||||
`Updating subscription item...`,
|
||||
);
|
||||
|
||||
await billingGateway.updateSubscriptionItem({
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
},
|
||||
`Subscription item updated successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to decrease seats for account ${accountId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createCreateTeamAccountService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new CreateTeamAccountService(client);
|
||||
}
|
||||
|
||||
class CreateTeamAccountService {
|
||||
private readonly namespace = 'accounts.create-team-account';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async createNewOrganizationAccount(params: { name: string; userId: string }) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { ...params, namespace: this.namespace };
|
||||
|
||||
logger.info(ctx, `Creating new team account...`);
|
||||
|
||||
const { error, data } = await this.client.rpc('create_team_account', {
|
||||
account_name: params.name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
...ctx,
|
||||
},
|
||||
`Error creating team account`,
|
||||
);
|
||||
|
||||
throw new Error('Error creating team account');
|
||||
}
|
||||
|
||||
logger.info(ctx, `Team account created successfully`);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createDeleteTeamAccountService() {
|
||||
return new DeleteTeamAccountService();
|
||||
}
|
||||
|
||||
class DeleteTeamAccountService {
|
||||
private readonly namespace = 'accounts.delete-team-account';
|
||||
|
||||
/**
|
||||
* Deletes a team account. Permissions are not checked here, as they are
|
||||
* checked in the server action.
|
||||
*
|
||||
* USE WITH CAUTION. THE USER MUST HAVE THE NECESSARY PERMISSIONS.
|
||||
*
|
||||
* @param adminClient
|
||||
* @param params
|
||||
*/
|
||||
async deleteTeamAccount(
|
||||
adminClient: SupabaseClient<Database>,
|
||||
params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
},
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
accountId: params.accountId,
|
||||
userId: params.userId,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Requested team account deletion. Processing...`);
|
||||
|
||||
// we can use the admin client to delete the account.
|
||||
const { error } = await adminClient
|
||||
.from('accounts')
|
||||
.delete()
|
||||
.eq('id', params.accountId);
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to delete team account',
|
||||
);
|
||||
|
||||
throw new Error('Failed to delete team account');
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Successfully deleted team account');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
const Schema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export function createLeaveTeamAccountService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new LeaveTeamAccountService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name LeaveTeamAccountService
|
||||
* @description Service for leaving a team account.
|
||||
*/
|
||||
class LeaveTeamAccountService {
|
||||
private readonly namespace = 'leave-team-account';
|
||||
|
||||
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name leaveTeamAccount
|
||||
* @description Leave a team account
|
||||
* @param params
|
||||
*/
|
||||
async leaveTeamAccount(params: z.infer<typeof Schema>) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
...params,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Leaving team account...');
|
||||
|
||||
const { accountId, userId } = Schema.parse(params);
|
||||
|
||||
const { error } = await this.adminClient
|
||||
.from('accounts_memberships')
|
||||
.delete()
|
||||
.match({
|
||||
account_id: accountId,
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to leave team account');
|
||||
|
||||
throw new Error('Failed to leave team account');
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Successfully left team account');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Invitation = Database['public']['Tables']['invitations']['Row'];
|
||||
|
||||
const invitePath = '/join';
|
||||
|
||||
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
||||
const emailSender = process.env.EMAIL_SENDER;
|
||||
|
||||
const env = z
|
||||
.object({
|
||||
invitePath: z
|
||||
.string({
|
||||
required_error: 'The property invitePath is required',
|
||||
})
|
||||
.min(1),
|
||||
siteURL: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||
})
|
||||
.min(1),
|
||||
productName: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
emailSender: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
invitePath,
|
||||
siteURL,
|
||||
productName,
|
||||
emailSender,
|
||||
});
|
||||
|
||||
export function createAccountInvitationsWebhookService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AccountInvitationsWebhookService(client);
|
||||
}
|
||||
|
||||
class AccountInvitationsWebhookService {
|
||||
private namespace = 'accounts.invitations.webhook';
|
||||
|
||||
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name handleInvitationWebhook
|
||||
* @description Handles the webhook event for invitations
|
||||
* @param invitation
|
||||
*/
|
||||
async handleInvitationWebhook(invitation: Invitation) {
|
||||
return this.dispatchInvitationEmail(invitation);
|
||||
}
|
||||
|
||||
private async dispatchInvitationEmail(invitation: Invitation) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ invitation, name: this.namespace },
|
||||
'Handling invitation webhook event...',
|
||||
);
|
||||
|
||||
const inviter = await this.adminClient
|
||||
.from('accounts')
|
||||
.select('email, name')
|
||||
.eq('id', invitation.invited_by)
|
||||
.single();
|
||||
|
||||
if (inviter.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: inviter.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch inviter details',
|
||||
);
|
||||
|
||||
throw inviter.error;
|
||||
}
|
||||
|
||||
const team = await this.adminClient
|
||||
.from('accounts')
|
||||
.select('name')
|
||||
.eq('id', invitation.account_id)
|
||||
.single();
|
||||
|
||||
if (team.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: team.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch team details',
|
||||
);
|
||||
|
||||
throw team.error;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
invitationId: invitation.id,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
|
||||
|
||||
try {
|
||||
const { renderInviteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
const link = this.getInvitationLink(
|
||||
invitation.invite_token,
|
||||
invitation.email,
|
||||
);
|
||||
|
||||
const { html, subject } = await renderInviteEmail({
|
||||
link,
|
||||
invitedUserEmail: invitation.email,
|
||||
inviter: inviter.data.name ?? inviter.data.email ?? '',
|
||||
productName: env.productName,
|
||||
teamName: team.data.name,
|
||||
});
|
||||
|
||||
await mailer
|
||||
.sendEmail({
|
||||
from: env.emailSender,
|
||||
to: invitation.email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(ctx, 'Invitation email successfully sent!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
logger.error({ error, ...ctx }, 'Failed to send invitation email');
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
|
||||
|
||||
return {
|
||||
error,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getInvitationLink(token: string, email: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
invite_token: token,
|
||||
email,
|
||||
}).toString();
|
||||
|
||||
const href = new URL(env.invitePath, env.siteURL).href;
|
||||
|
||||
return `${href}?${searchParams}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Account = Database['public']['Tables']['accounts']['Row'];
|
||||
|
||||
export function createAccountWebhooksService() {
|
||||
return new AccountWebhooksService();
|
||||
}
|
||||
|
||||
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, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: params.userDisplayName,
|
||||
productName: params.productName,
|
||||
});
|
||||
|
||||
return mailer.sendEmail({
|
||||
to: params.userEmail,
|
||||
from: params.fromEmail,
|
||||
subject,
|
||||
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({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
productName,
|
||||
fromEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './account-webhooks.service';
|
||||
export * from './account-invitations-webhook.service';
|
||||
Reference in New Issue
Block a user