Skip to content

Commit

Permalink
✨ feat: support server db with Postgres and drizzle OR
Browse files Browse the repository at this point in the history
  • Loading branch information
arvinxx committed Jun 3, 2024
1 parent 1d2bc0d commit 1c682f8
Show file tree
Hide file tree
Showing 65 changed files with 11,807 additions and 98 deletions.
29 changes: 29 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as dotenv from 'dotenv';
import type { Config } from 'drizzle-kit';

// Read the .env file if it exists, or a file specified by the

// dotenv_config_path parameter that's passed to Node.js

dotenv.config();

let connectionString = process.env.DATABASE_URL;

if (process.env.NODE_ENV === 'test') {
console.log('current ENV:', process.env.NODE_ENV);
connectionString = process.env.DATABASE_TEST_URL;
}

if (!connectionString)
throw new Error('`DATABASE_URL` or `DATABASE_TEST_URL` not found in environment');

export default {
dbCredentials: {
url: connectionString,
},
dialect: 'postgresql',
out: './src/database/server/migrations',

schema: './src/database/server/schemas/lobechat.ts',
strict: true,
} satisfies Config;
3 changes: 3 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ const nextConfig = {
},
});

// https://github.com/pinojs/pino/issues/688#issuecomment-637763276
config.externals.push('pino-pretty');

return config;
},
};
Expand Down
19 changes: 18 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,17 @@
"sideEffects": false,
"scripts": {
"build": "next build",
"postbuild": "npm run build-sitemap",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build-migrate-db": "bun run db:migrate",
"build-sitemap": "next-sitemap --config next-sitemap.config.mjs",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"db:generate": "drizzle-kit generate -- dotenv_config_path='.env'",
"db:migrate": "MIGRATION_DB=1 tsx scripts/migrateServerDB/index.ts",
"db:push": "drizzle-kit push -- dotenv_config_path='.env'",
"db:push-test": "NODE_ENV=test drizzle-kit push -- dotenv_config_path='.env'",
"db:studio": "drizzle-kit studio",
"db:z-pull": "drizzle-kit introspect -- dotenv_config_path='.env'",
"dev": "next dev -p 3010",
"dev:clerk-proxy": "ngrok http http://localhost:3011",
"docs:i18n": "lobe-i18n md && npm run workflow:docs && npm run lint:mdx",
Expand All @@ -50,6 +57,7 @@
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:db": "vitest run src/database/server/**",
"test:update": "vitest -u",
"type-check": "tsc --noEmit",
"workflow:docs": "tsx scripts/docsWorkflow/index.ts",
Expand Down Expand Up @@ -101,6 +109,7 @@
"@lobehub/tts": "^1.24.1",
"@lobehub/ui": "^1.141.2",
"@microsoft/fetch-event-source": "^2.0.1",
"@neondatabase/serverless": "^0.9.3",
"@next/third-parties": "^14.2.3",
"@sentry/nextjs": "^7.116.0",
"@t3-oss/env-nextjs": "^0.10.1",
Expand All @@ -119,6 +128,8 @@
"debug": "^4.3.4",
"dexie": "^3.2.7",
"diff": "^5.2.0",
"drizzle-orm": "^0.30.10",
"drizzle-zod": "^0.5.1",
"fast-deep-equal": "^3.1.3",
"gpt-tokenizer": "^2.1.2",
"i18next": "^23.11.5",
Expand Down Expand Up @@ -163,6 +174,7 @@
"semver": "^7.6.2",
"sharp": "^0.33.4",
"superjson": "^2.2.1",
"svix": "^1.24.0",
"swr": "^2.2.5",
"systemjs": "^6.15.1",
"ts-md5": "^1.3.1",
Expand All @@ -171,6 +183,7 @@
"use-merge-value": "^1.2.0",
"utility-types": "^3.11.0",
"uuid": "^9.0.1",
"ws": "^8.17.0",
"y-protocols": "^1.0.6",
"y-webrtc": "^10.3.0",
"yaml": "^2.4.2",
Expand Down Expand Up @@ -207,12 +220,15 @@
"@types/systemjs": "^6.13.5",
"@types/ua-parser-js": "^0.7.39",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"@umijs/lint": "^4.2.5",
"@vitest/coverage-v8": "~1.2.2",
"ajv-keywords": "^5.1.0",
"commitlint": "^19.3.0",
"consola": "^3.2.3",
"dotenv": "^16.4.5",
"dpdm": "^3.14.0",
"drizzle-kit": "^0.21.1",
"eslint": "^8.57.0",
"eslint-plugin-mdx": "^2.3.4",
"fake-indexeddb": "^6.0.0",
Expand All @@ -227,6 +243,7 @@
"node-fetch": "^3.3.2",
"node-gyp": "^10.1.0",
"p-map": "^7.0.2",
"pg": "^8.11.5",
"prettier": "^3.2.5",
"remark-cli": "^11.0.0",
"remark-parse": "^10.0.2",
Expand Down
30 changes: 30 additions & 0 deletions scripts/migrateServerDB/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as dotenv from 'dotenv';
import * as migrator from 'drizzle-orm/neon-serverless/migrator';
import { join } from 'node:path';

import { serverDB } from '../../src/database/server/core/db';

// Read the `.env` file if it exists, or a file specified by the
// dotenv_config_path parameter that's passed to Node.js
dotenv.config();

const runMigrations = async () => {
await migrator.migrate(serverDB, {
migrationsFolder: join(__dirname, '../../src/database/server/migrations'),
});
console.log('✅ database migration pass.');
// eslint-disable-next-line unicorn/no-process-exit
process.exit(0);
};

let connectionString = process.env.DATABASE_URL;

// only migrate database if the connection string is available
if (connectionString) {
// eslint-disable-next-line unicorn/prefer-top-level-await
runMigrations().catch((err) => {
console.error('❌ Database migrate failed:', err);
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
}
73 changes: 73 additions & 0 deletions src/app/api/webhooks/clerk/__tests__/fixtures/createUser.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"backup_code_enabled": false,
"banned": false,
"create_organization_enabled": true,
"created_at": 1713709987911,
"delete_self_enabled": true,
"email_addresses": [
{
"created_at": 1713709977919,
"email_address": "arvinx@foxmail.com",
"id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k",
"linked_to": [],
"object": "email_address",
"reserved": false,
"updated_at": 1713709987951,
"verification": []
}
],
"external_accounts": [
{
"approved_scopes": "read:user user:email",
"avatar_url": "https://avatars.githubusercontent.com/u/28616219?v=4",
"created_at": 1713709542104,
"email_address": "arvinx@foxmail.com",
"first_name": "Arvin",
"id": "eac_2fPjKROeJ1bBs8Uxa6RFMxKogTB",
"identification_id": "idn_2fPjyV3sqtQJZUbEzdK2y23a1bq",
"image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMjg2MTYyMTk/dj00IiwicyI6IkhCeHE5NmdlRk85ekRxMjJlR05EalUrbVFBbmVDZjRVQkpwNGYxcW5JajQifQ",
"label": null,
"last_name": "Xu",
"object": "external_account",
"provider": "oauth_github",
"provider_user_id": "28616219",
"public_metadata": {},
"updated_at": 1713709542104,
"username": "arvinxx",
"verification": {
"attempts": null,
"expire_at": 1713710140131,
"status": "verified",
"strategy": "oauth_github"
}
}
],
"external_id": null,
"first_name": "Arvin",
"has_image": true,
"id": "user_2fPkELglwI48WpZVwwdAxBKBPK6",
"image_url": "https://img.clerk.com/eyJ0eXBlIjoicHJveHkiLCJzcmMiOiJodHRwczovL2ltYWdlcy5jbGVyay5kZXYvb2F1dGhfZ2l0aHViL2ltZ18yZlBrRU1adVpwdlpvZFBHcVREdHJnTzJJM3cifQ",
"last_active_at": 1713709987902,
"last_name": "Xu",
"last_sign_in_at": null,
"locked": false,
"lockout_expires_in_seconds": null,
"object": "user",
"passkeys": [],
"password_enabled": false,
"phone_numbers": [],
"primary_email_address_id": "idn_2fPkD9X1lfzSn5lJVDGyochYq8k",
"primary_phone_number_id": null,
"primary_web3_wallet_id": null,
"private_metadata": {},
"profile_image_url": "https://images.clerk.dev/oauth_github/img_2fPkEMZuZpvZodPGqTDtrgO2I3w",
"public_metadata": {},
"saml_accounts": [],
"totp_enabled": false,
"two_factor_enabled": false,
"unsafe_metadata": {},
"updated_at": 1713709987972,
"username": "arvinxx",
"verification_attempts_remaining": 100,
"web3_wallets": []
}
159 changes: 159 additions & 0 deletions src/app/api/webhooks/clerk/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { UserJSON } from '@clerk/backend';
import { NextResponse } from 'next/server';

import { authEnv } from '@/config/auth';
import { isServerMode } from '@/const/version';
import { UserModel } from '@/database/server/models/user';
import { pino } from '@/libs/logger';

import { validateRequest } from './validateRequest';

if (authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH && isServerMode && !authEnv.CLERK_WEBHOOK_SECRET) {
throw new Error('`CLERK_WEBHOOK_SECRET` environment variable is missing');
}

const createUser = async (id: string, params: UserJSON) => {
pino.info('creating user due to clerk webhook');

const userModel = new UserModel();

// Check if user already exists
const res = await userModel.findById(id);

// If user already exists, skip creating a new user
if (res)
return NextResponse.json(
{ message: 'user not created due to user already existing in the database', success: false },
{ status: 200 },
);

const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id);
const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id);

await userModel.createUser({
avatar: params.image_url,
clerkCreatedAt: new Date(params.created_at),
email: email?.email_address,
firstName: params.first_name,
id,
lastName: params.last_name,
phone: phone?.phone_number,
username: params.username,
});

return NextResponse.json({ message: 'user created', success: true }, { status: 200 });
};

const deleteUser = async (id?: string) => {
if (id) {
pino.info('delete user due to clerk webhook');
const userModel = new UserModel();

await userModel.deleteUser(id);

return NextResponse.json({ message: 'user deleted' }, { status: 200 });
} else {
pino.warn('clerk sent a delete user request, but no user ID was included in the payload');
return NextResponse.json({ message: 'ok' }, { status: 200 });
}
};

const updateUser = async (id: string, params: UserJSON) => {
pino.info('updating user due to clerk webhook');

const userModel = new UserModel();

// Check if user already exists
const res = await userModel.findById(id);

// If user not exists, skip update the user
if (!res)
return NextResponse.json(
{
message: "user not updated due to the user don't existing in the database",
success: false,
},
{ status: 200 },
);

const email = params.email_addresses.find((e) => e.id === params.primary_email_address_id);
const phone = params.phone_numbers.find((e) => e.id === params.primary_phone_number_id);

await userModel.updateUser(id, {
avatar: params.image_url,
email: email?.email_address,
firstName: params.first_name,
id,
lastName: params.last_name,
phone: phone?.phone_number,
username: params.username,
});

return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
};

export const POST = async (req: Request): Promise<NextResponse> => {
const payload = await validateRequest(req, authEnv.CLERK_WEBHOOK_SECRET!);

if (!payload) {
return NextResponse.json(
{ error: 'webhook verification failed or payload was malformed' },
{ status: 400 },
);
}

const { type, data } = payload;

pino.trace(`clerk webhook payload: ${{ data, type }}`);

switch (type) {
case 'user.created': {
return createUser(data.id, data);
}
case 'user.deleted': {
return deleteUser(data.id);
}
case 'user.updated': {
return updateUser(data.id, data);
}

default: {
pino.warn(
`${req.url} received event type "${type}", but no handler is defined for this type`,
);
return NextResponse.json({ error: `uncreognised payload type: ${type}` }, { status: 400 });
}
// case 'user.updated':
// break;
// case 'session.created':
// break;
// case 'session.ended':
// break;
// case 'session.removed':
// break;
// case 'session.revoked':
// break;
// case 'email.created':
// break;
// case 'sms.created':
// break;
// case 'organization.created':
// break;
// case 'organization.updated':
// break;
// case 'organization.deleted':
// break;
// case 'organizationMembership.created':
// break;
// case 'organizationMembership.deleted':
// break;
// case 'organizationMembership.updated':
// break;
// case 'organizationInvitation.accepted':
// break;
// case 'organizationInvitation.created':
// break;
// case 'organizationInvitation.revoked':
// break;
}
};
Loading

0 comments on commit 1c682f8

Please sign in to comment.