B2B-88: add starter kit structure and elements
This commit is contained in:
24
packages/database-webhooks/README.md
Normal file
24
packages/database-webhooks/README.md
Normal 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.
|
||||
3
packages/database-webhooks/eslint.config.mjs
Normal file
3
packages/database-webhooks/eslint.config.mjs
Normal 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
1
packages/database-webhooks/node_modules/@kit/billing
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../billing/core
|
||||
1
packages/database-webhooks/node_modules/@kit/billing-gateway
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/billing-gateway
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../billing/gateway
|
||||
1
packages/database-webhooks/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/eslint
|
||||
1
packages/database-webhooks/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/prettier
|
||||
1
packages/database-webhooks/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../shared
|
||||
1
packages/database-webhooks/node_modules/@kit/stripe
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/stripe
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../billing/stripe
|
||||
1
packages/database-webhooks/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../supabase
|
||||
1
packages/database-webhooks/node_modules/@kit/team-accounts
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/team-accounts
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../features/team-accounts
|
||||
1
packages/database-webhooks/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/typescript
|
||||
1
packages/database-webhooks/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/database-webhooks/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -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
1
packages/database-webhooks/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
35
packages/database-webhooks/package.json
Normal file
35
packages/database-webhooks/package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/database-webhooks/src/index.ts
Normal file
1
packages/database-webhooks/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './server/services/database-webhook-handler.service';
|
||||
16
packages/database-webhooks/src/server/record-change.type.ts
Normal file
16
packages/database-webhooks/src/server/record-change.type.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export abstract class DatabaseWebhookVerifierService {
|
||||
abstract verifySignatureOrThrow(header: string): Promise<boolean>;
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
8
packages/database-webhooks/tsconfig.json
Normal file
8
packages/database-webhooks/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user