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,24 @@
# 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
The default sender provider is directly from the Postgres database.
```
WEBHOOK_SENDER_PROVIDER=postgres
```
Should you add a middleware to the webhook sender provider, you can do so by adding the following to the `WEBHOOK_SENDER_PROVIDER` environment variable.
```
WEBHOOK_SENDER_PROVIDER=svix
```
For example, you can add [https://docs.svix.com/quickstart]](Swix) as a webhook sender provider that receives webhooks from the database changes and forwards them to your application.
Svix is not implemented yet.

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

1
packages/database-webhooks/node_modules/@kit/billing generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../billing/core

View File

@@ -0,0 +1 @@
../../../billing/gateway

View File

@@ -0,0 +1 @@
../../../../tooling/eslint

View File

@@ -0,0 +1 @@
../../../../tooling/prettier

1
packages/database-webhooks/node_modules/@kit/shared generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../shared

1
packages/database-webhooks/node_modules/@kit/stripe generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../billing/stripe

1
packages/database-webhooks/node_modules/@kit/supabase generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../supabase

View File

@@ -0,0 +1 @@
../../../features/team-accounts

1
packages/database-webhooks/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/typescript

View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js

1
packages/database-webhooks/node_modules/zod generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View File

@@ -0,0 +1,35 @@
{
"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"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/team-accounts": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.49.4",
"zod": "^3.24.4"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './server/services/database-webhook-handler.service';

View File

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

View File

@@ -0,0 +1,88 @@
import 'server-only';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { RecordChange, Tables } from '../record-change.type';
import { createDatabaseWebhookRouterService } from './database-webhook-router.service';
import { getDatabaseWebhookVerifier } from './verifier';
/**
* @name DatabaseChangePayload
* @description Payload for the database change event. Useful for handling custom webhooks.
*/
export type DatabaseChangePayload<Table extends keyof Tables> =
RecordChange<Table>;
export function getDatabaseWebhookHandlerService() {
return new DatabaseWebhookHandlerService();
}
/**
* @name getDatabaseWebhookHandlerService
* @description Get the database webhook handler service
*/
class DatabaseWebhookHandlerService {
private readonly namespace = 'database-webhook-handler';
/**
* @name handleWebhook
* @description Handle the webhook event
* @param params
*/
async handleWebhook(params: {
body: RecordChange<keyof Tables>;
signature: string;
handleEvent?<Table extends keyof Tables>(
payload: Table extends keyof Tables
? DatabaseChangePayload<Table>
: never,
): unknown;
}) {
const logger = await getLogger();
const { table, type } = params.body;
const ctx = {
name: this.namespace,
table,
type,
};
logger.info(ctx, 'Received webhook from DB. Processing...');
// check if the signature is valid
const verifier = await getDatabaseWebhookVerifier();
await verifier.verifySignatureOrThrow(params.signature);
// all good, we can now the webhook
// create a client with admin access since we are handling webhooks and no user is authenticated
const adminClient = getSupabaseServerAdminClient();
const service = createDatabaseWebhookRouterService(adminClient);
try {
// handle the webhook event based on the table
await service.handleWebhook(params.body);
// if a custom handler is provided, call it
if (params?.handleEvent) {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
await params.handleEvent(params.body as any);
}
logger.info(ctx, 'Webhook processed successfully');
} catch (error) {
logger.error(
{
...ctx,
error,
},
'Failed to process webhook',
);
throw error;
}
}
}

View File

@@ -0,0 +1,86 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { RecordChange, Tables } from '../record-change.type';
export function createDatabaseWebhookRouterService(
adminClient: SupabaseClient<Database>,
) {
return new DatabaseWebhookRouterService(adminClient);
}
/**
* @name DatabaseWebhookRouterService
* @description Service that routes the webhook event to the appropriate service
*/
class DatabaseWebhookRouterService {
constructor(private readonly adminClient: SupabaseClient<Database>) {}
/**
* @name handleWebhook
* @description Handle the webhook event
* @param body
*/
async handleWebhook(body: RecordChange<keyof Tables>) {
switch (body.table) {
case 'invitations': {
const payload = body as RecordChange<typeof body.table>;
return this.handleInvitationsWebhook(payload);
}
case 'subscriptions': {
const payload = body as RecordChange<typeof body.table>;
return this.handleSubscriptionsWebhook(payload);
}
case 'accounts': {
const payload = body as RecordChange<typeof body.table>;
return this.handleAccountsWebhook(payload);
}
default: {
return;
}
}
}
private async handleInvitationsWebhook(body: RecordChange<'invitations'>) {
const { createAccountInvitationsWebhookService } = await import(
'@kit/team-accounts/webhooks'
);
const service = createAccountInvitationsWebhookService(this.adminClient);
return service.handleInvitationWebhook(body.record);
}
private async handleSubscriptionsWebhook(
body: RecordChange<'subscriptions'>,
) {
if (body.type === 'DELETE' && body.old_record) {
const { createBillingWebhooksService } = await import(
'@kit/billing-gateway'
);
const service = createBillingWebhooksService();
return service.handleSubscriptionDeletedWebhook(body.old_record);
}
}
private async handleAccountsWebhook(body: RecordChange<'accounts'>) {
if (body.type === 'DELETE' && body.old_record) {
const { createAccountWebhooksService } = await import(
'@kit/team-accounts/webhooks'
);
const service = createAccountWebhooksService();
return service.handleAccountDeletedWebhook(body.old_record);
}
}
}

View File

@@ -0,0 +1,3 @@
export abstract class DatabaseWebhookVerifierService {
abstract verifySignatureOrThrow(header: string): Promise<boolean>;
}

View File

@@ -0,0 +1,19 @@
const WEBHOOK_SENDER_PROVIDER =
process.env.WEBHOOK_SENDER_PROVIDER ?? 'postgres';
export async function getDatabaseWebhookVerifier() {
switch (WEBHOOK_SENDER_PROVIDER) {
case 'postgres': {
const { createDatabaseWebhookVerifierService } = await import(
'./postgres-database-webhook-verifier.service'
);
return createDatabaseWebhookVerifierService();
}
default:
throw new Error(
`Invalid WEBHOOK_SENDER_PROVIDER: ${WEBHOOK_SENDER_PROVIDER}`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service';
const webhooksSecret = z
.string({
description: `The secret used to verify the webhook signature`,
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
})
.min(1)
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);
export function createDatabaseWebhookVerifierService() {
return new PostgresDatabaseWebhookVerifierService();
}
class PostgresDatabaseWebhookVerifierService
implements DatabaseWebhookVerifierService
{
verifySignatureOrThrow(header: string) {
if (header !== webhooksSecret) {
throw new Error('Invalid signature');
}
return Promise.resolve(true);
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}