feat(auth): Add Clerk user sync to database using webhooks

This commit is contained in:
2024-11-21 01:53:43 -05:00
parent 8fc8da036c
commit 645b4b63aa
6 changed files with 375 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
{
"conventionalCommits.scopes": [
"homepage"
"homepage",
"repo"
]
}

View File

@@ -1,6 +1,9 @@
import 'dotenv/config';
import { config } from 'dotenv';
import { defineConfig } from 'drizzle-kit';
config({ path: '.env.local' });
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',

View File

@@ -0,0 +1,117 @@
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { WebhookEvent } from '@clerk/nextjs/server';
import { db } from '~/db';
import { usersTable, rolesTable, userRolesTable } from '~/db/schema';
import { eq } from 'drizzle-orm';
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error('Please add CLERK_WEBHOOK_SECRET from Clerk Dashboard to .env');
}
// Get the headers
const headersList = await headers();
const svix_id = headersList.get("svix-id");
const svix_timestamp = headersList.get("svix-timestamp");
const svix_signature = headersList.get("svix-signature");
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Error occurred -- no svix headers', {
status: 400
});
}
// Get the body
const payload = await req.json();
const body = JSON.stringify(payload);
// Verify the webhook
const webhook = new Webhook(WEBHOOK_SECRET);
let event: WebhookEvent;
try {
event = webhook.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error('Error verifying webhook:', err);
return new Response('Error occurred', {
status: 400
});
}
const eventType = event.type;
if (eventType === 'user.created' || eventType === 'user.updated') {
const { id, first_name, last_name, email_addresses, image_url } = event.data;
const primaryEmail = email_addresses?.[0]?.email_address;
if (!primaryEmail) {
return new Response('No email found', { status: 400 });
}
try {
// Combine first and last name
const fullName = [first_name, last_name].filter(Boolean).join(' ');
// Create/update user
await db
.insert(usersTable)
.values({
id,
name: fullName,
email: primaryEmail,
imageUrl: image_url,
})
.onConflictDoUpdate({
target: usersTable.id,
set: {
name: fullName,
email: primaryEmail,
imageUrl: image_url,
updatedAt: new Date(),
},
});
// Assign default role (Observer)
const observerRole = await db
.select()
.from(rolesTable)
.where(eq(rolesTable.name, 'Observer'))
.limit(1);
if (observerRole[0]) {
await db
.insert(userRolesTable)
.values({
userId: id,
roleId: observerRole[0].id,
})
.onConflictDoNothing();
}
return new Response('User created successfully', { status: 200 });
} catch (error) {
console.error('Error creating user:', error);
return new Response('Database error', { status: 500 });
}
}
if (eventType === 'user.deleted') {
try {
await db
.delete(usersTable)
.where(eq(usersTable.id, String(event.data.id)));
} catch (error) {
console.error('Error deleting user from database:', error);
return new Response('Database error', { status: 500 });
}
}
return new Response('Webhook processed successfully', { status: 200 });
}

28
src/db/drop.ts Normal file
View File

@@ -0,0 +1,28 @@
import { sql } from '@vercel/postgres';
import { db } from './index';
import { config } from 'dotenv';
// load .env.local
config({ path: '.env.local' });
async function dropAllTables() {
try {
await sql`
DROP TABLE IF EXISTS
user_roles,
role_permissions,
permissions,
roles,
participant,
study,
users
CASCADE;
`;
console.log('All tables dropped successfully');
} catch (error) {
console.error('Error dropping tables:', error);
process.exit(1);
}
}
dropAllTables();

View File

@@ -1,45 +1,43 @@
import { sql, relations } from 'drizzle-orm';
import { integer, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { integer, pgTable, serial, text, timestamp, varchar, primaryKey } from "drizzle-orm/pg-core";
export const usersTable = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
age: integer().notNull(),
email: varchar({ length: 255 }).notNull().unique(),
id: varchar("id", { length: 256 }).primaryKey(),
name: varchar("name", { length: 256 }),
email: varchar("email", { length: 256 }).notNull(),
imageUrl: varchar("image_url", { length: 512 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
});
export const studyTable = pgTable("study", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 256 }).notNull(),
description: varchar("description", { length: 1000 }),
userId: varchar("user_id", { length: 256 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
userId: varchar("user_id", { length: 256 })
.references(() => usersTable.id)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => new Date()),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
});
export const participants = pgTable(
"participant",
{
id: serial("id").primaryKey(),
name: varchar("name", { length: 256 }).notNull(),
studyId: integer("study_id").references(() => studyTable.id).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
}
);
export const participantsTable = pgTable("participant", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 256 }).notNull(),
studyId: integer("study_id")
.references(() => studyTable.id)
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const roles = pgTable("roles", {
export const rolesTable = pgTable("roles", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 256 }).notNull().unique(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const permissions = pgTable("permissions", {
export const permissionsTable = pgTable("permissions", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 256 }).notNull().unique(),
description: text("description"),
@@ -47,22 +45,75 @@ export const permissions = pgTable("permissions", {
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const rolePermissions = pgTable("role_permissions", {
roleId: integer("role_id").references(() => roles.id).notNull(),
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
});
export const userRoles = pgTable("user_roles", {
userId: varchar("user_id", { length: 256 }).notNull(),
roleId: integer("role_id").references(() => roles.id).notNull(),
});
// Add relations
export const rolesRelations = relations(roles, ({ many }) => ({
permissions: many(rolePermissions),
users: many(userRoles),
export const rolePermissionsTable = pgTable("role_permissions", {
roleId: integer("role_id")
.references(() => rolesTable.id)
.notNull(),
permissionId: integer("permission_id")
.references(() => permissionsTable.id)
.notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.roleId, table.permissionId] }),
}));
export const permissionsRelations = relations(permissions, ({ many }) => ({
roles: many(rolePermissions),
export const userRolesTable = pgTable("user_roles", {
userId: varchar("user_id", { length: 256 })
.references(() => usersTable.id)
.notNull(),
roleId: integer("role_id")
.references(() => rolesTable.id)
.notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.roleId] }),
}));
export const usersRelations = relations(usersTable, ({ many }) => ({
studies: many(studyTable),
userRoles: many(userRolesTable),
}));
export const studyRelations = relations(studyTable, ({ one, many }) => ({
user: one(usersTable, {
fields: [studyTable.userId],
references: [usersTable.id],
}),
participants: many(participantsTable),
}));
export const participantRelations = relations(participantsTable, ({ one }) => ({
study: one(studyTable, {
fields: [participantsTable.studyId],
references: [studyTable.id],
}),
}));
export const rolesRelations = relations(rolesTable, ({ many }) => ({
rolePermissions: many(rolePermissionsTable),
userRoles: many(userRolesTable),
}));
export const permissionsRelations = relations(permissionsTable, ({ many }) => ({
rolePermissions: many(rolePermissionsTable),
}));
export const rolePermissionsRelations = relations(rolePermissionsTable, ({ one }) => ({
role: one(rolesTable, {
fields: [rolePermissionsTable.roleId],
references: [rolesTable.id],
}),
permission: one(permissionsTable, {
fields: [rolePermissionsTable.permissionId],
references: [permissionsTable.id],
}),
}));
export const userRolesRelations = relations(userRolesTable, ({ one }) => ({
user: one(usersTable, {
fields: [userRolesTable.userId],
references: [usersTable.id],
}),
role: one(rolesTable, {
fields: [userRolesTable.roleId],
references: [rolesTable.id],
}),
}));

135
src/db/seed.ts Normal file
View File

@@ -0,0 +1,135 @@
import { db } from "~/db";
import {
permissionsTable,
rolesTable,
rolePermissionsTable,
} from "~/db/schema";
import { config } from "dotenv";
config({ path: ".env.local" });
async function seed() {
try {
console.log("Starting seed...");
// Create permissions
const createdPermissions = await db
.insert(permissionsTable)
.values([
{
name: "View Participant Names",
code: "view_participant_names",
description: "Can view participant names",
},
{
name: "Create Participant",
code: "create_participant",
description: "Can create new participants",
},
{
name: "Delete Participant",
code: "delete_participant",
description: "Can delete participants",
},
{
name: "Create Study",
code: "create_study",
description: "Can create new studies",
},
{
name: "Delete Study",
code: "delete_study",
description: "Can delete studies",
},
{
name: "Manage Roles",
code: "manage_roles",
description: "Can manage user roles",
},
])
.returning();
console.log("Created permissions:", createdPermissions);
// Create roles
const createdRoles = await db
.insert(rolesTable)
.values([
{
name: "Admin",
description: "Full system access",
},
{
name: "Researcher",
description: "Can manage studies and participants",
},
{
name: "Observer",
description: "Can view participant names only",
},
])
.returning();
console.log("Created roles:", createdRoles);
// Find roles by name
const adminRole = createdRoles.find((r) => r.name === "Admin");
const researcherRole = createdRoles.find((r) => r.name === "Researcher");
const observerRole = createdRoles.find((r) => r.name === "Observer");
// Assign permissions to roles
if (adminRole) {
// Admin gets all permissions
await db.insert(rolePermissionsTable).values(
createdPermissions.map((p) => ({
roleId: adminRole.id,
permissionId: p.id,
}))
);
console.log("Assigned all permissions to Admin role");
}
if (researcherRole) {
// Researcher gets specific permissions
const researcherPermissions = createdPermissions.filter((p) =>
[
"view_participant_names",
"create_participant",
"create_study",
].includes(p.code)
);
await db.insert(rolePermissionsTable).values(
researcherPermissions.map((p) => ({
roleId: researcherRole.id,
permissionId: p.id,
}))
);
console.log("Assigned permissions to Researcher role");
}
if (observerRole) {
// Observer gets view-only permissions
const observerPermissions = createdPermissions.filter((p) =>
["view_participant_names"].includes(p.code)
);
await db.insert(rolePermissionsTable).values(
observerPermissions.map((p) => ({
roleId: observerRole.id,
permissionId: p.id,
}))
);
console.log("Assigned permissions to Observer role");
}
console.log("Seeding completed successfully");
} catch (error) {
console.error("Error seeding database:", error);
throw error;
}
}
seed()
.catch(console.error)
.finally(() => process.exit());