B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -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,
},
);

View File

@@ -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;
}

View File

@@ -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');
},
{},
);

View File

@@ -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,
},
);

View File

@@ -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');
}

View File

@@ -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,
},
);

View 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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 };
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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}`;
}
}

View File

@@ -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,
});
}
}

View File

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