mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 06:34:44 -05:00
feat(auth): Add Clerk user sync to database using webhooks
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"conventionalCommits.scopes": [
|
||||
"homepage"
|
||||
"homepage",
|
||||
"repo"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
117
src/app/api/webhooks/clerk/route.ts
Normal file
117
src/app/api/webhooks/clerk/route.ts
Normal 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
28
src/db/drop.ts
Normal 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();
|
||||
129
src/db/schema.ts
129
src/db/schema.ts
@@ -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
135
src/db/seed.ts
Normal 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());
|
||||
Reference in New Issue
Block a user