diff --git a/.vscode/settings.json b/.vscode/settings.json index 9b5f4a5..171b326 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "conventionalCommits.scopes": [ - "homepage" + "homepage", + "repo" ] } \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index 45ab249..a9620ae 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -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', diff --git a/src/app/api/webhooks/clerk/route.ts b/src/app/api/webhooks/clerk/route.ts new file mode 100644 index 0000000..deccfef --- /dev/null +++ b/src/app/api/webhooks/clerk/route.ts @@ -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 }); +} \ No newline at end of file diff --git a/src/db/drop.ts b/src/db/drop.ts new file mode 100644 index 0000000..d2f0602 --- /dev/null +++ b/src/db/drop.ts @@ -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(); \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 3b55119..1ffcf4d 100644 --- a/src/db/schema.ts +++ b/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], + }), })); \ No newline at end of file diff --git a/src/db/seed.ts b/src/db/seed.ts new file mode 100644 index 0000000..03479c6 --- /dev/null +++ b/src/db/seed.ts @@ -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());