mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -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": [
|
"conventionalCommits.scopes": [
|
||||||
"homepage"
|
"homepage",
|
||||||
|
"repo"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { config } from 'dotenv';
|
||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
config({ path: '.env.local' });
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/db/schema.ts',
|
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 { 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", {
|
export const usersTable = pgTable("users", {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: varchar("id", { length: 256 }).primaryKey(),
|
||||||
name: varchar({ length: 255 }).notNull(),
|
name: varchar("name", { length: 256 }),
|
||||||
age: integer().notNull(),
|
email: varchar("email", { length: 256 }).notNull(),
|
||||||
email: varchar({ length: 255 }).notNull().unique(),
|
imageUrl: varchar("image_url", { length: 512 }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const studyTable = pgTable("study", {
|
export const studyTable = pgTable("study", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
title: varchar("title", { length: 256 }).notNull(),
|
title: varchar("title", { length: 256 }).notNull(),
|
||||||
description: varchar("description", { length: 1000 }),
|
description: varchar("description", { length: 1000 }),
|
||||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
userId: varchar("user_id", { length: 256 })
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
.references(() => usersTable.id)
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
.$onUpdate(() => new Date()),
|
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const participants = pgTable(
|
export const participantsTable = pgTable("participant", {
|
||||||
"participant",
|
id: serial("id").primaryKey(),
|
||||||
{
|
name: varchar("name", { length: 256 }).notNull(),
|
||||||
id: serial("id").primaryKey(),
|
studyId: integer("study_id")
|
||||||
name: varchar("name", { length: 256 }).notNull(),
|
.references(() => studyTable.id)
|
||||||
studyId: integer("study_id").references(() => studyTable.id).notNull(),
|
.notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
});
|
||||||
.notNull(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const roles = pgTable("roles", {
|
export const rolesTable = pgTable("roles", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permissions = pgTable("permissions", {
|
export const permissionsTable = pgTable("permissions", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: varchar("name", { length: 256 }).notNull().unique(),
|
name: varchar("name", { length: 256 }).notNull().unique(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
@@ -47,22 +45,75 @@ export const permissions = pgTable("permissions", {
|
|||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rolePermissions = pgTable("role_permissions", {
|
export const rolePermissionsTable = pgTable("role_permissions", {
|
||||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
roleId: integer("role_id")
|
||||||
permissionId: integer("permission_id").references(() => permissions.id).notNull(),
|
.references(() => rolesTable.id)
|
||||||
});
|
.notNull(),
|
||||||
|
permissionId: integer("permission_id")
|
||||||
export const userRoles = pgTable("user_roles", {
|
.references(() => permissionsTable.id)
|
||||||
userId: varchar("user_id", { length: 256 }).notNull(),
|
.notNull(),
|
||||||
roleId: integer("role_id").references(() => roles.id).notNull(),
|
}, (table) => ({
|
||||||
});
|
pk: primaryKey({ columns: [table.roleId, table.permissionId] }),
|
||||||
|
|
||||||
// Add relations
|
|
||||||
export const rolesRelations = relations(roles, ({ many }) => ({
|
|
||||||
permissions: many(rolePermissions),
|
|
||||||
users: many(userRoles),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const permissionsRelations = relations(permissions, ({ many }) => ({
|
export const userRolesTable = pgTable("user_roles", {
|
||||||
roles: many(rolePermissions),
|
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