diff --git a/apps/web/.env.development b/apps/web/.env.development index 83f2d963a..8ee84a2c1 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -17,9 +17,11 @@ NEXT_PUBLIC_BILLING_PROVIDER=stripe # SUPABASE NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 - NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true +## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION +SUPABASE_WEBHOOK_SECRET=WEBHOOKSECRET + EMAIL_SENDER=test@makerkit.dev EMAIL_PORT=54325 EMAIL_HOST=localhost diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index b6494fcc1..6c10c6a7a 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -37,7 +37,7 @@ export function PersonalAccountCheckoutForm() { // Otherwise, render the plan picker component return ( -
+
Manage your Plan diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index dba87c227..de7bf2e28 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -40,26 +40,24 @@ async function PersonalAccountBillingPage() { /> -
-
- } - > - {(subscription) => ( - - )} - +
+ } + > + {(subscription) => ( + + )} + - -
- - -
-
+ +
+ + +
diff --git a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts index 766d96cea..b30443992 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts @@ -4,7 +4,6 @@ import { redirect } from 'next/navigation'; import { z } from 'zod'; -import { getLineItemsFromPlanId } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; diff --git a/apps/web/app/api/database/webhook.ts b/apps/web/app/api/database/webhook.ts deleted file mode 100644 index efe1e2ecd..000000000 --- a/apps/web/app/api/database/webhook.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function POST(request: Request) { - console.log(request); -} diff --git a/apps/web/app/api/database/webhook/route.ts b/apps/web/app/api/database/webhook/route.ts new file mode 100644 index 000000000..0144f3e73 --- /dev/null +++ b/apps/web/app/api/database/webhook/route.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { DatabaseWebhookHandlerService } from '@kit/database-webhooks'; + +const webhooksSecret = z + .string({ + description: `The secret used to verify the webhook signature`, + }) + .min(1) + .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); + +export async function POST(request: Request) { + const service = new DatabaseWebhookHandlerService(); + + await service.handleWebhook(request, webhooksSecret); + + return new Response(null, { + status: 200, + }); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 2c54f38b7..7c200e984 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,5 @@ import { Inter as SansFont } from 'next/font/google'; -import { cookies } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { Toaster } from '@kit/ui/sonner'; import { cn } from '@kit/ui/utils'; @@ -27,6 +27,8 @@ export default async function RootLayout({ return ( + + {children} @@ -70,3 +72,9 @@ export const metadata = { }, }, }; + +function CsrfTokenMeta() { + const csrf = headers().get('x-csrf-token') ?? ''; + + return ; +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 1e23658b5..0710cda7b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -16,6 +16,7 @@ const INTERNAL_PACKAGES = [ '@kit/billing-gateway', '@kit/stripe', '@kit/email-templates', + '@kit/database-webhooks' ]; /** @type {import('next').NextConfig} */ diff --git a/apps/web/package.json b/apps/web/package.json index e70326a32..98b398fc4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@kit/auth": "workspace:^", "@kit/billing": "workspace:^", "@kit/billing-gateway": "workspace:^", + "@kit/database-webhooks": "workspace:^", "@kit/email-templates": "workspace:^", "@kit/i18n": "workspace:^", "@kit/mailers": "workspace:^", diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-plan-card.tsx index a829726f6..a1384f3b6 100644 --- a/packages/billing-gateway/src/components/current-plan-card.tsx +++ b/packages/billing-gateway/src/components/current-plan-card.tsx @@ -1,7 +1,11 @@ import { formatDate } from 'date-fns'; import { BadgeCheck, CheckCircle2 } from 'lucide-react'; -import { BillingConfig, getProductPlanPair } from '@kit/billing'; +import { + BillingConfig, + getBaseLineItem, + getProductPlanPair, +} from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; import { Database } from '@kit/supabase/database'; import { @@ -31,6 +35,7 @@ export function CurrentPlanCard({ config: BillingConfig; }>) { const { plan, product } = getProductPlanPair(config, subscription.variant_id); + const baseLineItem = getBaseLineItem(config, plan.id); return ( @@ -62,7 +67,7 @@ export function CurrentPlanCard({ i18nKey="billing:planRenewal" values={{ interval: subscription.interval, - price: formatCurrency(product.currency, plan.price), + price: formatCurrency(product.currency, baseLineItem.price), }} />
@@ -111,19 +116,6 @@ export function CurrentPlanCard({
- -
- Your next bill - -
- Your next bill is for {product.currency} {plan.price} on{' '} - - {formatDate(subscription.period_ends_at ?? '', 'P')} - {' '} -
-
-
-
Features diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx index a602ac126..ffd94aea0 100644 --- a/packages/billing-gateway/src/components/plan-picker.tsx +++ b/packages/billing-gateway/src/components/plan-picker.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, CheckCircle } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -24,6 +24,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { Heading } from '@kit/ui/heading'; import { If } from '@kit/ui/if'; import { Label } from '@kit/ui/label'; import { @@ -31,6 +32,7 @@ import { RadioGroupItem, RadioGroupItemLabel, } from '@kit/ui/radio-group'; +import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; @@ -81,189 +83,240 @@ export function PlanPicker( const { interval: selectedInterval } = form.watch(); const planId = form.getValues('planId'); - const selectedPlan = useMemo(() => { + const { plan: selectedPlan, product: selectedProduct } = useMemo(() => { try { - return getProductPlanPair(props.config, planId).plan; + return getProductPlanPair(props.config, planId); } catch { - return; + return { + plan: null, + product: null, + }; } - }, [form, props.config, planId]); + }, [props.config, planId]); return (
- - { - return ( - - - Choose your billing interval - +
+ + { + return ( + + + Choose your billing interval + - - -
- {intervals.map((interval) => { - const selected = field.value === interval; + + +
+ {intervals.map((interval) => { + const selected = field.value === interval; - return ( -
-
-
- - - ); - }} - /> - ( - - Pick your preferred plan + + + + + ); + })} +
+
+
+ +
+ ); + }} + /> - - - {props.config.products.map((product) => { - const plan = product.plans.find( - (item) => item.interval === selectedInterval, - ); + ( + + Pick your preferred plan - if (!plan) { - return null; - } + + + {props.config.products.map((product) => { + const plan = product.plans.find( + (item) => item.interval === selectedInterval, + ); - const baseLineItem = getBaseLineItem(props.config, plan.id); + if (!plan) { + return null; + } - return ( - - { - form.setValue('planId', plan.id, { - shouldValidate: true, - }); + const baseLineItem = getBaseLineItem( + props.config, + plan.id, + ); - form.setValue('productId', product.id, { - shouldValidate: true, - }); - }} - /> - -
- + form.setValue('productId', product.id, { + shouldValidate: true, + }); + }} + />
- -
- - {plan.trialPeriod} day trial - -
-
+
- - ); - })} - - + + ); + })} + + - - - )} - /> - -
- -
- + /> + +
+ +
+ + + +
+
+ + {selectedProduct?.name} + + +

+ + {selectedProduct?.description} + +

+
+ +
+ {selectedProduct?.features.map((item) => { + return ( +
+ + + {item} +
+ ); + })} +
+ + +
+
+
); } diff --git a/packages/billing-gateway/src/index.ts b/packages/billing-gateway/src/index.ts index abb2865b6..38ecdabfd 100644 --- a/packages/billing-gateway/src/index.ts +++ b/packages/billing-gateway/src/index.ts @@ -1,4 +1,4 @@ export * from './server/services/billing-gateway/billing-gateway.service'; export * from './server/services/billing-gateway/billing-gateway-provider-factory'; export * from './server/services/billing-event-handler/billing-gateway-provider-factory'; -export * from './server/services/account-billing.service'; +export * from './server/services/billing-webhooks/billing-webhooks.service'; diff --git a/packages/billing-gateway/src/server/services/account-billing.service.ts b/packages/billing-gateway/src/server/services/account-billing.service.ts deleted file mode 100644 index 727e04e97..000000000 --- a/packages/billing-gateway/src/server/services/account-billing.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SupabaseClient } from '@supabase/supabase-js'; - -import { Logger } from '@kit/shared/logger'; -import { Database } from '@kit/supabase/database'; - -import { BillingGatewayService } from './billing-gateway/billing-gateway.service'; - -export class AccountBillingService { - private readonly namespace = 'accounts.billing'; - - constructor(private readonly client: SupabaseClient) {} - - async cancelAllAccountSubscriptions({ - accountId, - userId, - }: { - accountId: string; - userId: string; - }) { - Logger.info( - { - userId, - accountId, - name: this.namespace, - }, - 'Cancelling all subscriptions for account...', - ); - - const { data: subscriptions } = await this.client - .from('subscriptions') - .select('*') - .eq('account_id', accountId); - - const cancellationRequests = []; - - Logger.info( - { - userId, - subscriptions: subscriptions?.length ?? 0, - name: this.namespace, - }, - 'Cancelling all account subscriptions...', - ); - - for (const subscription of subscriptions ?? []) { - const gateway = new BillingGatewayService(subscription.billing_provider); - - cancellationRequests.push( - gateway.cancelSubscription({ - subscriptionId: subscription.id, - invoiceNow: true, - }), - ); - } - - // execute all cancellation requests - await Promise.all(cancellationRequests); - - Logger.info( - { - userId, - subscriptions: subscriptions?.length ?? 0, - name: this.namespace, - }, - 'Subscriptions cancelled successfully', - ); - } -} diff --git a/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts b/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts index 258616b99..e065d94f0 100644 --- a/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts +++ b/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts @@ -1,10 +1,13 @@ import { z } from 'zod'; -import { BillingProvider, BillingWebhookHandlerService } from '@kit/billing'; +import { + BillingProviderSchema, + BillingWebhookHandlerService, +} from '@kit/billing'; export class BillingEventHandlerFactoryService { static async GetProviderStrategy( - provider: z.infer, + provider: z.infer, ): Promise { switch (provider) { case 'stripe': { diff --git a/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts b/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts new file mode 100644 index 000000000..976ff5c92 --- /dev/null +++ b/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts @@ -0,0 +1,15 @@ +import { Database } from '@kit/supabase/database'; + +import { BillingGatewayService } from '../billing-gateway/billing-gateway.service'; + +type Subscription = Database['public']['Tables']['subscriptions']['Row']; + +export class BillingWebhooksService { + async handleSubscriptionDeletedWebhook(subscription: Subscription) { + const gateway = new BillingGatewayService(subscription.billing_provider); + + await gateway.cancelSubscription({ + subscriptionId: subscription.id, + }); + } +} diff --git a/packages/database-webhooks/README.md b/packages/database-webhooks/README.md new file mode 100644 index 000000000..bdf32bb5b --- /dev/null +++ b/packages/database-webhooks/README.md @@ -0,0 +1,8 @@ +# Database Webhooks - @kit/database-webhooks + +This package is responsible for handling webhooks from database changes. + +For example: +1. when an account is deleted, we handle the cleanup of all related data in the third-party services. +2. when a user is invited, we send an email to the user. +3. when an account member is added, we update the subscription in the third-party services \ No newline at end of file diff --git a/packages/database-webhooks/package.json b/packages/database-webhooks/package.json new file mode 100644 index 000000000..bae9cb87e --- /dev/null +++ b/packages/database-webhooks/package.json @@ -0,0 +1,50 @@ +{ + "name": "@kit/database-webhooks", + "private": true, + "version": "0.1.0", + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "format": "prettier --check \"**/*.{ts,tsx}\"", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "prettier": "@kit/prettier-config", + "exports": { + ".": "./src/index.ts" + }, + "peerDependencies": { + "@kit/billing-gateway": "workspace:^", + "@kit/team-accounts": "workspace:^", + "@kit/shared": "^0.1.0", + "@kit/supabase": "^0.1.0", + "@supabase/supabase-js": "^2.40.0" + }, + "devDependencies": { + "@kit/billing": "workspace:^", + "@kit/eslint-config": "workspace:*", + "@kit/prettier-config": "workspace:*", + "@kit/shared": "workspace:^", + "@kit/stripe": "workspace:^", + "@kit/supabase": "workspace:^", + "@kit/tailwind-config": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@kit/ui": "workspace:^", + "@supabase/supabase-js": "^2.41.1", + "lucide-react": "^0.363.0", + "zod": "^3.22.4" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@kit/eslint-config/base", + "@kit/eslint-config/react" + ] + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + } +} \ No newline at end of file diff --git a/packages/database-webhooks/src/index.ts b/packages/database-webhooks/src/index.ts new file mode 100644 index 000000000..072c8917a --- /dev/null +++ b/packages/database-webhooks/src/index.ts @@ -0,0 +1 @@ +export * from './server/services/database-webhook-handler.service'; diff --git a/packages/database-webhooks/src/server/record-change.type.ts b/packages/database-webhooks/src/server/record-change.type.ts new file mode 100644 index 000000000..fd1565f22 --- /dev/null +++ b/packages/database-webhooks/src/server/record-change.type.ts @@ -0,0 +1,16 @@ +import { Database } from '@kit/supabase/database'; + +export type Tables = Database['public']['Tables']; + +export type TableChangeType = 'INSERT' | 'UPDATE' | 'DELETE'; + +export interface RecordChange< + Table extends keyof Tables, + Row = Tables[Table]['Row'], +> { + type: TableChangeType; + table: Table; + record: Row; + schema: 'public'; + old_record: null | Row; +} diff --git a/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts new file mode 100644 index 000000000..f437be6f1 --- /dev/null +++ b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts @@ -0,0 +1,43 @@ +import { Logger } from '@kit/shared/logger'; +import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client'; + +import { RecordChange, Tables } from '../record-change.type'; +import { DatabaseWebhookRouterService } from './database-webhook-router.service'; + +export class DatabaseWebhookHandlerService { + private readonly namespace = 'database-webhook-handler'; + + async handleWebhook(request: Request, webhooksSecret: string) { + Logger.info( + { + name: this.namespace, + }, + 'Received webhook from DB. Processing...', + ); + + // check if the signature is valid + this.assertSignatureIsAuthentic(request, webhooksSecret); + + const json = await request.json(); + + await this.handleWebhookBody(json); + } + + private handleWebhookBody(body: RecordChange) { + const client = getSupabaseRouteHandlerClient({ + admin: true, + }); + + const service = new DatabaseWebhookRouterService(client); + + return service.handleWebhook(body); + } + + private assertSignatureIsAuthentic(request: Request, webhooksSecret: string) { + const header = request.headers.get('X-Supabase-Event-Signature'); + + if (header !== webhooksSecret) { + throw new Error('Invalid signature'); + } + } +} diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts new file mode 100644 index 000000000..50ce0a9d6 --- /dev/null +++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts @@ -0,0 +1,58 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { Database } from '@kit/supabase/database'; + +import { RecordChange, Tables } from '../record-change.type'; + +export class DatabaseWebhookRouterService { + constructor(private readonly adminClient: SupabaseClient) {} + + handleWebhook(body: RecordChange) { + switch (body.table) { + case 'invitations': { + const payload = body as RecordChange; + + return this.handleInvitations(payload); + } + + case 'subscriptions': { + const payload = body as RecordChange; + + return this.handleSubscriptions(payload); + } + + case 'accounts_memberships': { + const payload = body as RecordChange; + + return this.handleAccountsMemberships(payload); + } + + default: + throw new Error('No handler for this table'); + } + } + + private async handleInvitations(body: RecordChange<'invitations'>) { + const { AccountInvitationsWebhookService } = await import( + '@kit/team-accounts/webhooks' + ); + + const service = new AccountInvitationsWebhookService(this.adminClient); + + return service.handleInvitationWebhook(body.record); + } + + private async handleSubscriptions(body: RecordChange<'subscriptions'>) { + const { BillingWebhooksService } = await import('@kit/billing-gateway'); + const service = new BillingWebhooksService(); + + return service.handleSubscriptionDeletedWebhook(body.record); + } + + private handleAccountsMemberships( + payload: RecordChange<'accounts_memberships'>, + ) { + // no-op + return Promise.resolve(undefined); + } +} diff --git a/packages/database-webhooks/tsconfig.json b/packages/database-webhooks/tsconfig.json new file mode 100644 index 000000000..c4697e934 --- /dev/null +++ b/packages/database-webhooks/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@kit/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/features/accounts/src/server/services/delete-personal-account.service.ts b/packages/features/accounts/src/server/services/delete-personal-account.service.ts index 5a43f387a..139f300e1 100644 --- a/packages/features/accounts/src/server/services/delete-personal-account.service.ts +++ b/packages/features/accounts/src/server/services/delete-personal-account.service.ts @@ -1,6 +1,5 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { AccountBillingService } from '@kit/billing-gateway'; import { Mailer } from '@kit/mailers'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -42,14 +41,6 @@ export class DeletePersonalAccountService { 'User requested deletion. Processing...', ); - // Cancel all user subscriptions - const billingService = new AccountBillingService(params.adminClient); - - await billingService.cancelAllAccountSubscriptions({ - userId, - accountId: userId, - }); - // execute the deletion of the user try { await params.adminClient.auth.admin.deleteUser(userId); diff --git a/packages/features/team-accounts/package.json b/packages/features/team-accounts/package.json index db4a501c9..1ad3484d4 100644 --- a/packages/features/team-accounts/package.json +++ b/packages/features/team-accounts/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit" }, "exports": { - "./components": "./src/components/index.ts" + "./components": "./src/components/index.ts", + "./webhooks": "./src/server/services/account-invitations-webhook.service.ts" }, "devDependencies": { "@hookform/resolvers": "^3.3.4", diff --git a/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts b/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts new file mode 100644 index 000000000..9efa414ce --- /dev/null +++ b/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts @@ -0,0 +1,105 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { z } from 'zod'; + +import { Mailer } from '@kit/mailers'; +import { Logger } from '@kit/shared/logger'; +import { Database } from '@kit/supabase/database'; + +type Invitation = Database['public']['Tables']['invitations']['Row']; + +const invitePath = process.env.INVITATION_PAGE_PATH; +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().min(1), + siteURL: z.string().min(1), + productName: z.string(), + emailSender: z.string().email(), + }) + .parse({ + invitePath, + siteURL, + productName, + emailSender, + }); + +export class AccountInvitationsWebhookService { + private namespace = 'accounts.invitations.webhook'; + + constructor(private readonly client: SupabaseClient) {} + + async handleInvitationWebhook(invitation: Invitation) { + return this.dispatchInvitationEmail(invitation); + } + + private async dispatchInvitationEmail(invitation: Invitation) { + const mailer = new Mailer(); + + const inviter = await this.client + .from('accounts') + .select('email, name') + .eq('id', invitation.invited_by) + .single(); + + if (inviter.error) { + throw inviter.error; + } + + const team = await this.client + .from('accounts') + .select('name') + .eq('id', invitation.account_id) + .single(); + + if (team.error) { + throw team.error; + } + + try { + const { renderInviteEmail } = await import('@kit/email-templates'); + + const html = renderInviteEmail({ + link: this.getInvitationLink(invitation.invite_token), + 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: 'You have been invited to join a team', + html, + }); + + Logger.info('Invitation email sent', { + email: invitation.email, + account: invitation.account_id, + name: this.namespace, + }); + + return { + success: true, + }; + } catch (error) { + Logger.warn( + { error, name: this.namespace }, + 'Failed to send invitation email', + ); + + return { + error, + success: false, + }; + } + } + + private getInvitationLink(token: string) { + return new URL(env.invitePath, env.siteURL).href + `?invite_token=${token}`; + } +} diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index bcc691f61..8ec335c9c 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -4,34 +4,13 @@ import { addDays, formatISO } from 'date-fns'; import 'server-only'; import { z } from 'zod'; -import { Mailer } from '@kit/mailers'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; -import { requireUser } from '@kit/supabase/require-user'; import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { UpdateInvitationSchema } from '../../schema/update-invitation.schema'; -const invitePath = process.env.INVITATION_PAGE_PATH; -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().min(1), - siteURL: z.string().min(1), - productName: z.string(), - emailSender: z.string().email(), - }) - .parse({ - invitePath, - siteURL, - productName, - emailSender, - }); - export class AccountInvitationsService { private namespace = 'accounts.invitations'; @@ -101,9 +80,6 @@ export class AccountInvitationsService { 'Storing invitations', ); - const mailer = new Mailer(); - const user = await this.getUser(); - const accountResponse = await this.client .from('accounts') .select('name') @@ -123,8 +99,6 @@ export class AccountInvitationsService { throw response.error; } - const promises = []; - const responseInvitations = Array.isArray(response.data) ? response.data : [response.data]; @@ -137,74 +111,6 @@ export class AccountInvitationsService { }, 'Invitations added to account', ); - - Logger.info( - { - account, - count: responseInvitations.length, - name: this.namespace, - }, - 'Sending invitation emails...', - ); - - for (const invitation of responseInvitations) { - const promise = async () => { - try { - const { renderInviteEmail } = await import('@kit/email-templates'); - - const html = renderInviteEmail({ - link: this.getInvitationLink(invitation.invite_token), - invitedUserEmail: invitation.email, - inviter: user.email, - productName: env.productName, - teamName: accountResponse.data.name, - }); - - await mailer.sendEmail({ - from: env.emailSender, - to: invitation.email, - subject: 'You have been invited to join a team', - html, - }); - - Logger.info('Invitation email sent', { - email: invitation.email, - account, - name: this.namespace, - }); - - return { - success: true, - }; - } catch (error) { - console.error(error); - Logger.warn( - { account, error, name: this.namespace }, - 'Failed to send invitation email', - ); - - return { - error, - success: false, - }; - } - }; - - promises.push(promise); - } - - const responses = await Promise.all(promises.map((promise) => promise())); - const success = responses.filter((response) => response.success).length; - - Logger.info( - { - name: this.namespace, - account, - success, - failed: responses.length - success, - }, - `Invitations processed`, - ); } /** @@ -255,18 +161,4 @@ export class AccountInvitationsService { return data; } - - private async getUser() { - const { data, error } = await requireUser(this.client); - - if (error ?? !data) { - throw new Error('Authentication required'); - } - - return data; - } - - private getInvitationLink(token: string) { - return new URL(env.invitePath, env.siteURL).href + `?invite_token=${token}`; - } } diff --git a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts index d42f95e03..bf527c447 100644 --- a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts @@ -2,7 +2,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; import 'server-only'; -import { AccountBillingService } from '@kit/billing-gateway'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -34,21 +33,7 @@ export class DeleteTeamAccountService { `Requested team account deletion. Processing...`, ); - Logger.info( - { - name: this.namespace, - accountId: params.accountId, - userId: params.userId, - }, - `Deleting all account subscriptions...`, - ); - - // First - we want to cancel all Stripe active subscriptions - const billingService = new AccountBillingService(adminClient); - - await billingService.cancelAllAccountSubscriptions(params); - - // now we can use the admin client to delete the account. + // we can use the admin client to delete the account. const { error } = await adminClient .from('accounts') .delete() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05adf289c..16a1d07b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@kit/billing-gateway': specifier: workspace:^ version: link:../../packages/billing-gateway + '@kit/database-webhooks': + specifier: workspace:^ + version: link:../../packages/database-webhooks '@kit/email-templates': specifier: workspace:^ version: link:../../packages/email-templates @@ -265,6 +268,52 @@ importers: specifier: ^3.22.4 version: 3.22.4 + packages/database-webhooks: + dependencies: + '@kit/billing-gateway': + specifier: workspace:^ + version: link:../billing-gateway + '@kit/team-accounts': + specifier: workspace:^ + version: link:../features/team-accounts + devDependencies: + '@kit/billing': + specifier: workspace:^ + version: link:../billing + '@kit/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@kit/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@kit/shared': + specifier: workspace:^ + version: link:../shared + '@kit/stripe': + specifier: workspace:^ + version: link:../stripe + '@kit/supabase': + specifier: workspace:^ + version: link:../supabase + '@kit/tailwind-config': + specifier: workspace:* + version: link:../../tooling/tailwind + '@kit/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@kit/ui': + specifier: workspace:^ + version: link:../ui + '@supabase/supabase-js': + specifier: ^2.41.1 + version: 2.41.1 + lucide-react: + specifier: ^0.363.0 + version: 0.363.0(react@18.2.0) + zod: + specifier: ^3.22.4 + version: 3.22.4 + packages/email-templates: dependencies: '@react-email/components': @@ -767,9 +816,6 @@ importers: eslint-plugin-import: specifier: ^2.29.1 version: 2.29.1(@typescript-eslint/parser@7.4.0)(eslint@8.57.0) - eslint-plugin-jsx-a11y: - specifier: ^6.8.0 - version: 6.8.0(eslint@8.57.0) eslint-plugin-react: specifier: ^7.34.1 version: 7.34.1(eslint@8.57.0) @@ -4918,12 +4964,6 @@ packages: tslib: 2.6.2 dev: false - /aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - dependencies: - dequal: 2.0.3 - dev: false - /array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -5030,10 +5070,6 @@ packages: is-shared-array-buffer: 1.0.3 dev: false - /ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - dev: false - /ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -5089,17 +5125,6 @@ packages: possible-typed-array-names: 1.0.0 dev: false - /axe-core@4.7.0: - resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} - engines: {node: '>=4'} - dev: false - - /axobject-query@3.2.1: - resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} - dependencies: - dequal: 2.0.3 - dev: false - /babel-walk@3.0.0: resolution: {integrity: sha512-fdRxJkQ9MUSEi4jH2DcV3FAPFktk0wefilxrwNyUuWpoWawQGN7G7cB+fOYTtFfI6XNkFgwqJ/D3G18BoJJ/jg==} engines: {node: '>= 10.0.0'} @@ -5695,10 +5720,6 @@ packages: engines: {node: '>=12'} dev: false - /damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - dev: false - /data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -6376,31 +6397,6 @@ packages: - supports-color dev: false - /eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0): - resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - dependencies: - '@babel/runtime': 7.24.1 - aria-query: 5.3.0 - array-includes: 3.1.8 - array.prototype.flatmap: 1.3.2 - ast-types-flow: 0.0.8 - axe-core: 4.7.0 - axobject-query: 3.2.1 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.18 - eslint: 8.57.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.entries: 1.1.8 - object.fromentries: 2.0.8 - dev: false - /eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} @@ -7905,17 +7901,6 @@ packages: engines: {node: '>=6'} dev: false - /language-subtag-registry@0.3.22: - resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} - dev: false - - /language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - dependencies: - language-subtag-registry: 0.3.22 - dev: false - /leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} dev: false diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index cd1e4b2c5..d012c9626 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -437,11 +437,15 @@ create table if not exists public.roles ( primary key (name) ); +grant select on table public.roles to authenticated, service_role; + -- Seed the roles table with default roles 'owner' and 'member' insert into public.roles (name, hierarchy_level) values ('owner', 1); insert into public.roles (name, hierarchy_level) values ('member', 2); -- RLS +alter table public.roles enable row level security; + -- SELECT: authenticated users can query roles create policy roles_read on public.roles for select diff --git a/supabase/seed.sql b/supabase/seed.sql index e69de29bb..b44a50283 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -0,0 +1,37 @@ +-- These webhooks are only for development purposes. +-- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so). +-- We don't do it because you'll need to manually add your webhook URL and secret key. + +-- this webhook will be triggered after every insert on the accounts_memberships table +create trigger "accounts_memberships_insert" after insert +on "public"."accounts_memberships" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); + +-- this webhook will be triggered after every insert on the accounts_memberships table +create trigger "account_membership_delete" after insert +on "public"."accounts_memberships" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); + +-- this webhook will be triggered after a delete on the subscriptions table +-- which should happen when a user deletes their account (and all their subscriptions) +create trigger "account_delete" after delete +on "public"."subscriptions" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); \ No newline at end of file