From be8f62375cb7a5798f9133dcf454eea7f835617b Mon Sep 17 00:00:00 2001
From: Joachim
Date: Tue, 10 Mar 2026 15:37:50 +0100
Subject: [PATCH 01/36] feat: add interview validation schemas for creation,
updating, and querying
---
app/components/AppSidebar.vue | 244 --
app/components/AppTopBar.vue | 3 +-
app/components/InterviewScheduleSidebar.vue | 543 ++++
app/composables/useInterviews.ts | 160 +-
app/pages/dashboard/interviews.vue | 802 ++++++
app/pages/dashboard/jobs/[id]/index.vue | 41 +-
server/api/interviews/[id]/index.delete.ts | 34 +
server/api/interviews/[id]/index.get.ts | 44 +
server/api/interviews/[id]/index.patch.ts | 63 +
server/api/interviews/index.get.ts | 64 +
server/api/interviews/index.post.ts | 57 +
.../0010_glamorous_mattie_franklin.sql | 27 +
.../migrations/meta/0010_snapshot.json | 2500 +++++++++++++++++
server/database/migrations/meta/_journal.json | 7 +
server/database/schema/app.ts | 47 +
server/utils/schemas/interview.ts | 56 +
shared/permissions.ts | 10 +-
17 files changed, 4445 insertions(+), 257 deletions(-)
delete mode 100644 app/components/AppSidebar.vue
create mode 100644 app/components/InterviewScheduleSidebar.vue
create mode 100644 app/pages/dashboard/interviews.vue
create mode 100644 server/api/interviews/[id]/index.delete.ts
create mode 100644 server/api/interviews/[id]/index.get.ts
create mode 100644 server/api/interviews/[id]/index.patch.ts
create mode 100644 server/api/interviews/index.get.ts
create mode 100644 server/api/interviews/index.post.ts
create mode 100644 server/database/migrations/0010_glamorous_mattie_franklin.sql
create mode 100644 server/database/migrations/meta/0010_snapshot.json
create mode 100644 server/utils/schemas/interview.ts
diff --git a/app/components/AppSidebar.vue b/app/components/AppSidebar.vue
deleted file mode 100644
index 47aa9d66..00000000
--- a/app/components/AppSidebar.vue
+++ /dev/null
@@ -1,244 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue
index abbb545a..5989f253 100644
--- a/app/components/AppTopBar.vue
+++ b/app/components/AppTopBar.vue
@@ -4,7 +4,7 @@ import {
Kanban, FileText, LogOut, Table2,
Sun, Moon, MessageSquarePlus, Settings,
ChevronDown, Menu, X, Users, ChevronLeft,
- LayoutDashboard,
+ LayoutDashboard, Calendar,
} from 'lucide-vue-next'
const route = useRoute()
@@ -104,6 +104,7 @@ const mainNav = [
{ label: 'Jobs', to: '/dashboard', icon: Briefcase, exact: true },
{ label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false },
{ label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false },
+ { label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false },
{ label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false },
]
diff --git a/app/components/InterviewScheduleSidebar.vue b/app/components/InterviewScheduleSidebar.vue
new file mode 100644
index 00000000..ea55337e
--- /dev/null
+++ b/app/components/InterviewScheduleSidebar.vue
@@ -0,0 +1,543 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Schedule Interview
+
+
+ {{ candidateName }} · {{ jobTitle }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ errors.submit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ errors.title }}
+
+
+
+
+
+
+
+
+
+ {{ calendarMonthLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ errors.date }}
+
+
+
+
+
+
+
+
+
{{ errors.time }}
+
+
+
+
+
+
+
+
+
+ {{ form.duration }} min
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scheduled for
+
{{ formattedDateTime }}
+
{{ form.duration }} min · ends at {{ endTime }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/composables/useInterviews.ts b/app/composables/useInterviews.ts
index 8ddf1879..6c323048 100644
--- a/app/composables/useInterviews.ts
+++ b/app/composables/useInterviews.ts
@@ -1,10 +1,160 @@
+import type { MaybeRefOrGetter } from 'vue'
+
export interface Interview {
id: string
- scheduledAt: string
- duration: number
+ title: string
type: 'phone' | 'video' | 'in_person' | 'panel' | 'technical' | 'take_home'
status: 'scheduled' | 'completed' | 'cancelled' | 'no_show'
- title: string
- candidateFirstName?: string
- candidateLastName?: string
+ scheduledAt: string
+ duration: number
+ location: string | null
+ notes: string | null
+ interviewers: string[] | null
+ applicationId: string
+ candidateFirstName: string
+ candidateLastName: string
+ candidateEmail: string
+ candidatePhone?: string | null
+ jobId: string
+ jobTitle: string
+ createdAt: string
+ updatedAt: string
+}
+
+interface InterviewListResponse {
+ data: Interview[]
+ total: number
+ page: number
+ limit: number
+}
+
+/**
+ * Composable for listing interviews with filters.
+ */
+export function useInterviews(options?: {
+ applicationId?: MaybeRefOrGetter
+ jobId?: MaybeRefOrGetter
+ status?: MaybeRefOrGetter
+ from?: MaybeRefOrGetter
+ to?: MaybeRefOrGetter
+ limit?: MaybeRefOrGetter
+}) {
+ const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+
+ const query = computed(() => {
+ const q: Record = {}
+ if (options?.applicationId) {
+ const v = toValue(options.applicationId)
+ if (v) q.applicationId = v
+ }
+ if (options?.jobId) {
+ const v = toValue(options.jobId)
+ if (v) q.jobId = v
+ }
+ if (options?.status) {
+ const v = toValue(options.status)
+ if (v) q.status = v
+ }
+ if (options?.from) {
+ const v = toValue(options.from)
+ if (v) q.from = v
+ }
+ if (options?.to) {
+ const v = toValue(options.to)
+ if (v) q.to = v
+ }
+ if (options?.limit) {
+ const v = toValue(options.limit)
+ if (v) q.limit = v
+ }
+ return q
+ })
+
+ const { data, status, error, refresh } = useFetch('/api/interviews', {
+ key: 'interviews',
+ query,
+ headers: useRequestHeaders(['cookie']),
+ })
+
+ const interviews = computed(() => data.value?.data ?? [])
+ const total = computed(() => data.value?.total ?? 0)
+
+ async function createInterview(payload: {
+ applicationId: string
+ title: string
+ type?: Interview['type']
+ scheduledAt: string
+ duration?: number
+ location?: string
+ notes?: string
+ interviewers?: string[]
+ }) {
+ try {
+ const created = await $fetch('/api/interviews', {
+ method: 'POST',
+ body: payload,
+ })
+ await refresh()
+ return created
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ return { interviews, total, status, error, refresh, createInterview }
+}
+
+/**
+ * Composable for a single interview detail with update/delete mutations.
+ */
+export function useInterview(id: MaybeRefOrGetter) {
+ const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+ const interviewId = computed(() => toValue(id))
+
+ const { data: interview, status, error, refresh } = useFetch(
+ () => `/api/interviews/${interviewId.value}`,
+ {
+ key: computed(() => `interview-${interviewId.value}`),
+ headers: useRequestHeaders(['cookie']),
+ },
+ )
+
+ async function updateInterview(payload: Partial<{
+ title: string
+ type: Interview['type']
+ status: Interview['status']
+ scheduledAt: string
+ duration: number
+ location: string | null
+ notes: string | null
+ interviewers: string[] | null
+ }>) {
+ try {
+ const updated = await $fetch(`/api/interviews/${interviewId.value}`, {
+ method: 'PATCH',
+ body: payload,
+ })
+ await refresh()
+ await refreshNuxtData('interviews')
+ return updated
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ async function deleteInterview() {
+ try {
+ await $fetch(`/api/interviews/${interviewId.value}`, {
+ method: 'DELETE',
+ })
+ await refreshNuxtData('interviews')
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ return { interview, status, error, refresh, updateInterview, deleteInterview }
}
diff --git a/app/pages/dashboard/interviews.vue b/app/pages/dashboard/interviews.vue
new file mode 100644
index 00000000..6adef9fe
--- /dev/null
+++ b/app/pages/dashboard/interviews.vue
@@ -0,0 +1,802 @@
+
+
+
+
+
+
+
+
+
+ Interviews
+
+
+ Manage all scheduled interviews across your jobs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading interviews…
+
+
+
+
+ Failed to load interviews. Please try again.
+
+
+
+
+
+
+
+
+ {{ searchInput || activeStatus ? 'No matching interviews' : 'No interviews yet' }}
+
+
+ {{ searchInput || activeStatus
+ ? 'Try adjusting your filters.'
+ : 'Interviews will appear here when you schedule them from the pipeline.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getCandidateInitials(interviewItem.candidateFirstName, interviewItem.candidateLastName) }}
+
+
+
+
+
+
+ {{ interviewItem.title }}
+
+
+
+ {{ statusConfig[interviewItem.status]?.label }}
+
+
+
+
+
+
+
+ {{ interviewItem.candidateFirstName }} {{ interviewItem.candidateLastName }}
+
+
+
+ {{ interviewItem.jobTitle }}
+
+
+
+
+
+
+
+ {{ formatDateShort(interviewItem.scheduledAt) }}
+
+
+
+ {{ formatTime(interviewItem.scheduledAt) }} · {{ interviewItem.duration }}min
+
+
+
+ {{ typeLabels[interviewItem.type] }}
+
+
+
+ {{ interviewItem.location }}
+
+
+
+ {{ interviewItem.interviewers.join(', ') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ dateLabel }}
+
{{ dateInterviews.length }} interview{{ dateInterviews.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatTime(interviewItem.scheduledAt) }}
+
+ {{ interviewItem.duration }}min
+
+ {{ statusConfig[interviewItem.status]?.label }}
+
+
+
{{ interviewItem.title }}
+
+
+
+ {{ interviewItem.candidateFirstName }} {{ interviewItem.candidateLastName }}
+
+
+
+ {{ typeLabels[interviewItem.type] }}
+
+
+
+ {{ interviewItem.location }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Interview
+
+
+ {{ editErrors.submit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Interview
+
+ Are you sure you want to delete {{ deletingInterview?.title }}? This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/jobs/[id]/index.vue b/app/pages/dashboard/jobs/[id]/index.vue
index 73f28e1f..485897d4 100644
--- a/app/pages/dashboard/jobs/[id]/index.vue
+++ b/app/pages/dashboard/jobs/[id]/index.vue
@@ -316,6 +316,35 @@ function selectCandidate(index: number) {
const isMutating = ref(false)
+// ─────────────────────────────────────────────
+// Interview scheduling sidebar
+// ─────────────────────────────────────────────
+
+const showInterviewSidebar = ref(false)
+const interviewTargetApplication = ref<{ id: string; name: string } | null>(null)
+
+function openInterviewScheduler() {
+ if (!currentSummary.value) return
+ interviewTargetApplication.value = {
+ id: currentSummary.value.id,
+ name: `${currentSummary.value.candidateFirstName} ${currentSummary.value.candidateLastName}`,
+ }
+ showInterviewSidebar.value = true
+}
+
+async function handleInterviewScheduled() {
+ showInterviewSidebar.value = false
+ interviewTargetApplication.value = null
+
+ // Transition the application status to 'interview' after scheduling
+ if (currentSummary.value && currentSummary.value.status !== 'interview') {
+ const allowed = APPLICATION_STATUS_TRANSITIONS[currentSummary.value.status] ?? []
+ if (allowed.includes('interview')) {
+ await changeStatus('interview')
+ }
+ }
+}
+
async function changeStatus(status: string) {
if (!currentSummary.value || isMutating.value) return
const applicationId = currentSummary.value.id
@@ -854,7 +883,7 @@ function closeDocPreview() {
:disabled="isMutating"
class="cursor-pointer rounded-lg px-3 py-1.5 text-xs font-semibold transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
:class="transitionClasses[nextStatus] ?? 'border border-surface-300 text-surface-600 hover:bg-surface-50'"
- @click="changeStatus(nextStatus)"
+ @click="nextStatus === 'interview' ? openInterviewScheduler() : changeStatus(nextStatus)"
>
{{ transitionLabels[nextStatus] ?? nextStatus }}
@@ -1289,6 +1318,16 @@ function closeDocPreview() {
@created="handleCandidateApplied"
/>
+
+
+
diff --git a/server/api/interviews/[id]/index.delete.ts b/server/api/interviews/[id]/index.delete.ts
new file mode 100644
index 00000000..cf51752c
--- /dev/null
+++ b/server/api/interviews/[id]/index.delete.ts
@@ -0,0 +1,34 @@
+import { and, eq } from 'drizzle-orm'
+import { interview } from '../../../database/schema'
+import { interviewIdParamSchema } from '../../../utils/schemas/interview'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['delete'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, interviewIdParamSchema.parse)
+
+ const current = await db.query.interview.findFirst({
+ where: and(eq(interview.id, id), eq(interview.organizationId, orgId)),
+ columns: { id: true },
+ })
+
+ if (!current) {
+ throw createError({ statusCode: 404, statusMessage: 'Interview not found' })
+ }
+
+ await db.delete(interview).where(
+ and(eq(interview.id, id), eq(interview.organizationId, orgId)),
+ )
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'deleted',
+ resourceType: 'interview',
+ resourceId: id,
+ })
+
+ setResponseStatus(event, 204)
+ return null
+})
diff --git a/server/api/interviews/[id]/index.get.ts b/server/api/interviews/[id]/index.get.ts
new file mode 100644
index 00000000..07882280
--- /dev/null
+++ b/server/api/interviews/[id]/index.get.ts
@@ -0,0 +1,44 @@
+import { and, eq } from 'drizzle-orm'
+import { interview, application, candidate, job } from '../../../database/schema'
+import { interviewIdParamSchema } from '../../../utils/schemas/interview'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['read'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, interviewIdParamSchema.parse)
+
+ const [data] = await db
+ .select({
+ id: interview.id,
+ title: interview.title,
+ type: interview.type,
+ status: interview.status,
+ scheduledAt: interview.scheduledAt,
+ duration: interview.duration,
+ location: interview.location,
+ notes: interview.notes,
+ interviewers: interview.interviewers,
+ createdById: interview.createdById,
+ createdAt: interview.createdAt,
+ updatedAt: interview.updatedAt,
+ applicationId: interview.applicationId,
+ candidateFirstName: candidate.firstName,
+ candidateLastName: candidate.lastName,
+ candidateEmail: candidate.email,
+ candidatePhone: candidate.phone,
+ jobId: application.jobId,
+ jobTitle: job.title,
+ })
+ .from(interview)
+ .innerJoin(application, eq(application.id, interview.applicationId))
+ .innerJoin(candidate, eq(candidate.id, application.candidateId))
+ .innerJoin(job, eq(job.id, application.jobId))
+ .where(and(eq(interview.id, id), eq(interview.organizationId, orgId)))
+
+ if (!data) {
+ throw createError({ statusCode: 404, statusMessage: 'Interview not found' })
+ }
+
+ return data
+})
diff --git a/server/api/interviews/[id]/index.patch.ts b/server/api/interviews/[id]/index.patch.ts
new file mode 100644
index 00000000..8cfa9edf
--- /dev/null
+++ b/server/api/interviews/[id]/index.patch.ts
@@ -0,0 +1,63 @@
+import { and, eq } from 'drizzle-orm'
+import { interview } from '../../../database/schema'
+import { interviewIdParamSchema, updateInterviewSchema, INTERVIEW_STATUS_TRANSITIONS } from '../../../utils/schemas/interview'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['update'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, interviewIdParamSchema.parse)
+ const body = await readValidatedBody(event, updateInterviewSchema.parse)
+
+ // Fetch current interview for validation
+ const current = await db.query.interview.findFirst({
+ where: and(eq(interview.id, id), eq(interview.organizationId, orgId)),
+ columns: { id: true, status: true },
+ })
+
+ if (!current) {
+ throw createError({ statusCode: 404, statusMessage: 'Interview not found' })
+ }
+
+ // Validate status transition
+ if (body.status && body.status !== current.status) {
+ const allowed = INTERVIEW_STATUS_TRANSITIONS[current.status] ?? []
+ if (!allowed.includes(body.status)) {
+ throw createError({
+ statusCode: 422,
+ statusMessage: `Cannot transition from "${current.status}" to "${body.status}"`,
+ })
+ }
+ }
+
+ const updateData: Record
= { updatedAt: new Date() }
+ if (body.title !== undefined) updateData.title = body.title
+ if (body.type !== undefined) updateData.type = body.type
+ if (body.status !== undefined) updateData.status = body.status
+ if (body.scheduledAt !== undefined) updateData.scheduledAt = new Date(body.scheduledAt)
+ if (body.duration !== undefined) updateData.duration = body.duration
+ if (body.location !== undefined) updateData.location = body.location
+ if (body.notes !== undefined) updateData.notes = body.notes
+ if (body.interviewers !== undefined) updateData.interviewers = body.interviewers
+
+ const [updated] = await db
+ .update(interview)
+ .set(updateData)
+ .where(and(eq(interview.id, id), eq(interview.organizationId, orgId)))
+ .returning()
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: body.status && body.status !== current.status ? 'status_changed' : 'updated',
+ resourceType: 'interview',
+ resourceId: id,
+ metadata: {
+ ...(body.status && body.status !== current.status
+ ? { from: current.status, to: body.status }
+ : {}),
+ },
+ })
+
+ return updated
+})
diff --git a/server/api/interviews/index.get.ts b/server/api/interviews/index.get.ts
new file mode 100644
index 00000000..2988fe09
--- /dev/null
+++ b/server/api/interviews/index.get.ts
@@ -0,0 +1,64 @@
+import { and, desc, eq, gte, lte } from 'drizzle-orm'
+import { interview, application, candidate, job } from '../../database/schema'
+import { interviewQuerySchema } from '../../utils/schemas/interview'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['read'] })
+ const orgId = session.session.activeOrganizationId
+
+ const query = await getValidatedQuery(event, interviewQuerySchema.parse)
+
+ const conditions = [eq(interview.organizationId, orgId)]
+
+ if (query.applicationId) {
+ conditions.push(eq(interview.applicationId, query.applicationId))
+ }
+ if (query.jobId) {
+ conditions.push(eq(application.jobId, query.jobId))
+ }
+ if (query.status) {
+ conditions.push(eq(interview.status, query.status))
+ }
+ if (query.from) {
+ conditions.push(gte(interview.scheduledAt, new Date(query.from)))
+ }
+ if (query.to) {
+ conditions.push(lte(interview.scheduledAt, new Date(query.to)))
+ }
+
+ const whereClause = and(...conditions)
+
+ const [data, total] = await Promise.all([
+ db
+ .select({
+ id: interview.id,
+ title: interview.title,
+ type: interview.type,
+ status: interview.status,
+ scheduledAt: interview.scheduledAt,
+ duration: interview.duration,
+ location: interview.location,
+ notes: interview.notes,
+ interviewers: interview.interviewers,
+ createdAt: interview.createdAt,
+ updatedAt: interview.updatedAt,
+ applicationId: interview.applicationId,
+ candidateFirstName: candidate.firstName,
+ candidateLastName: candidate.lastName,
+ candidateEmail: candidate.email,
+ jobId: application.jobId,
+ jobTitle: job.title,
+ })
+ .from(interview)
+ .innerJoin(application, eq(application.id, interview.applicationId))
+ .innerJoin(candidate, eq(candidate.id, application.candidateId))
+ .innerJoin(job, eq(job.id, application.jobId))
+ .where(whereClause)
+ .orderBy(desc(interview.scheduledAt))
+ .limit(query.limit)
+ .offset((query.page - 1) * query.limit),
+ db.$count(interview, whereClause),
+ ])
+
+ return { data, total, page: query.page, limit: query.limit }
+})
diff --git a/server/api/interviews/index.post.ts b/server/api/interviews/index.post.ts
new file mode 100644
index 00000000..d1b15870
--- /dev/null
+++ b/server/api/interviews/index.post.ts
@@ -0,0 +1,57 @@
+import { and, eq } from 'drizzle-orm'
+import { interview, application } from '../../database/schema'
+import { createInterviewSchema } from '../../utils/schemas/interview'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['create'] })
+ const orgId = session.session.activeOrganizationId
+
+ const body = await readValidatedBody(event, createInterviewSchema.parse)
+
+ // Verify the application exists and belongs to this org
+ const app = await db.query.application.findFirst({
+ where: and(
+ eq(application.id, body.applicationId),
+ eq(application.organizationId, orgId),
+ ),
+ columns: { id: true, status: true },
+ })
+
+ if (!app) {
+ throw createError({
+ statusCode: 404,
+ statusMessage: 'Application not found',
+ })
+ }
+
+ const [created] = await db.insert(interview).values({
+ organizationId: orgId,
+ applicationId: body.applicationId,
+ title: body.title,
+ type: body.type,
+ scheduledAt: new Date(body.scheduledAt),
+ duration: body.duration,
+ location: body.location ?? null,
+ notes: body.notes ?? null,
+ interviewers: body.interviewers ?? null,
+ createdById: session.user.id,
+ }).returning()
+
+ if (!created) throw createError({ statusCode: 500, statusMessage: 'Failed to create interview' })
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'created',
+ resourceType: 'interview',
+ resourceId: created.id,
+ metadata: {
+ applicationId: body.applicationId,
+ title: body.title,
+ scheduledAt: body.scheduledAt,
+ },
+ })
+
+ setResponseStatus(event, 201)
+ return created
+})
diff --git a/server/database/migrations/0010_glamorous_mattie_franklin.sql b/server/database/migrations/0010_glamorous_mattie_franklin.sql
new file mode 100644
index 00000000..cea965c6
--- /dev/null
+++ b/server/database/migrations/0010_glamorous_mattie_franklin.sql
@@ -0,0 +1,27 @@
+CREATE TYPE "public"."interview_status" AS ENUM('scheduled', 'completed', 'cancelled', 'no_show');--> statement-breakpoint
+CREATE TYPE "public"."interview_type" AS ENUM('phone', 'video', 'in_person', 'panel', 'technical', 'take_home');--> statement-breakpoint
+CREATE TABLE "interview" (
+ "id" text PRIMARY KEY NOT NULL,
+ "organization_id" text NOT NULL,
+ "application_id" text NOT NULL,
+ "title" text NOT NULL,
+ "type" "interview_type" DEFAULT 'video' NOT NULL,
+ "status" "interview_status" DEFAULT 'scheduled' NOT NULL,
+ "scheduled_at" timestamp NOT NULL,
+ "duration" integer DEFAULT 60 NOT NULL,
+ "location" text,
+ "notes" text,
+ "interviewers" jsonb,
+ "created_by_id" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "interview" ADD CONSTRAINT "interview_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "interview" ADD CONSTRAINT "interview_application_id_application_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."application"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "interview" ADD CONSTRAINT "interview_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "interview_organization_id_idx" ON "interview" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "interview_application_id_idx" ON "interview" USING btree ("application_id");--> statement-breakpoint
+CREATE INDEX "interview_scheduled_at_idx" ON "interview" USING btree ("scheduled_at");--> statement-breakpoint
+CREATE INDEX "interview_status_idx" ON "interview" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "interview_created_by_id_idx" ON "interview" USING btree ("created_by_id");
\ No newline at end of file
diff --git a/server/database/migrations/meta/0010_snapshot.json b/server/database/migrations/meta/0010_snapshot.json
new file mode 100644
index 00000000..5271fd48
--- /dev/null
+++ b/server/database/migrations/meta/0010_snapshot.json
@@ -0,0 +1,2500 @@
+{
+ "id": "aa26921f-7e41-4926-9cd4-b4b9f70fa220",
+ "prevId": "3c48c462-0d4e-415e-8a86-c1084c226da9",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invitation_organization_id_idx": {
+ "name": "invitation_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "member_user_id_idx": {
+ "name": "member_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_organization_id_idx": {
+ "name": "member_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_user_org_unique_idx": {
+ "name": "member_user_org_unique_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.activity_log": {
+ "name": "activity_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "activity_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_type": {
+ "name": "resource_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "activity_log_organization_id_idx": {
+ "name": "activity_log_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_actor_id_idx": {
+ "name": "activity_log_actor_id_idx",
+ "columns": [
+ {
+ "expression": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_resource_idx": {
+ "name": "activity_log_resource_idx",
+ "columns": [
+ {
+ "expression": "resource_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_created_at_idx": {
+ "name": "activity_log_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "activity_log_organization_id_organization_id_fk": {
+ "name": "activity_log_organization_id_organization_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "activity_log_actor_id_user_id_fk": {
+ "name": "activity_log_actor_id_user_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "user",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.application": {
+ "name": "application",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_id": {
+ "name": "candidate_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "application_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_letter_text": {
+ "name": "cover_letter_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "application_organization_id_idx": {
+ "name": "application_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_candidate_id_idx": {
+ "name": "application_candidate_id_idx",
+ "columns": [
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_job_id_idx": {
+ "name": "application_job_id_idx",
+ "columns": [
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_org_candidate_job_idx": {
+ "name": "application_org_candidate_job_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "application_organization_id_organization_id_fk": {
+ "name": "application_organization_id_organization_id_fk",
+ "tableFrom": "application",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_candidate_id_candidate_id_fk": {
+ "name": "application_candidate_id_candidate_id_fk",
+ "tableFrom": "application",
+ "tableTo": "candidate",
+ "columnsFrom": [
+ "candidate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_job_id_job_id_fk": {
+ "name": "application_job_id_job_id_fk",
+ "tableFrom": "application",
+ "tableTo": "job",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.candidate": {
+ "name": "candidate",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "candidate_organization_id_idx": {
+ "name": "candidate_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "candidate_org_email_idx": {
+ "name": "candidate_org_email_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "candidate_organization_id_organization_id_fk": {
+ "name": "candidate_organization_id_organization_id_fk",
+ "tableFrom": "candidate",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comment": {
+ "name": "comment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "comment_target",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comment_organization_id_idx": {
+ "name": "comment_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comment_target_idx": {
+ "name": "comment_target_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comment_author_id_idx": {
+ "name": "comment_author_id_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comment_organization_id_organization_id_fk": {
+ "name": "comment_organization_id_organization_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comment_author_id_user_id_fk": {
+ "name": "comment_author_id_user_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "user",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document": {
+ "name": "document",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_id": {
+ "name": "candidate_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "document_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'resume'"
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parsed_content": {
+ "name": "parsed_content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_organization_id_idx": {
+ "name": "document_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_candidate_id_idx": {
+ "name": "document_candidate_id_idx",
+ "columns": [
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_organization_id_organization_id_fk": {
+ "name": "document_organization_id_organization_id_fk",
+ "tableFrom": "document",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_candidate_id_candidate_id_fk": {
+ "name": "document_candidate_id_candidate_id_fk",
+ "tableFrom": "document",
+ "tableTo": "candidate",
+ "columnsFrom": [
+ "candidate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "document_storage_key_unique": {
+ "name": "document_storage_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "storage_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.interview": {
+ "name": "interview",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "interview_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'video'"
+ },
+ "status": {
+ "name": "status",
+ "type": "interview_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'scheduled'"
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interviewers": {
+ "name": "interviewers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "interview_organization_id_idx": {
+ "name": "interview_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_application_id_idx": {
+ "name": "interview_application_id_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_scheduled_at_idx": {
+ "name": "interview_scheduled_at_idx",
+ "columns": [
+ {
+ "expression": "scheduled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_status_idx": {
+ "name": "interview_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_created_by_id_idx": {
+ "name": "interview_created_by_id_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "interview_organization_id_organization_id_fk": {
+ "name": "interview_organization_id_organization_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "interview_application_id_application_id_fk": {
+ "name": "interview_application_id_application_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "application",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "interview_created_by_id_user_id_fk": {
+ "name": "interview_created_by_id_user_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite_link": {
+ "name": "invite_link",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invite_link_organization_id_idx": {
+ "name": "invite_link_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_link_token_idx": {
+ "name": "invite_link_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invite_link_organization_id_organization_id_fk": {
+ "name": "invite_link_organization_id_organization_id_fk",
+ "tableFrom": "invite_link",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invite_link_created_by_id_user_id_fk": {
+ "name": "invite_link_created_by_id_user_id_fk",
+ "tableFrom": "invite_link",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_link_token_unique": {
+ "name": "invite_link_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.job": {
+ "name": "job",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "job_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'full_time'"
+ },
+ "status": {
+ "name": "status",
+ "type": "job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "salary_min": {
+ "name": "salary_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_max": {
+ "name": "salary_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_currency": {
+ "name": "salary_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_unit": {
+ "name": "salary_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_status": {
+ "name": "remote_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valid_through": {
+ "name": "valid_through",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_resume": {
+ "name": "require_resume",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "require_cover_letter": {
+ "name": "require_cover_letter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "job_organization_id_idx": {
+ "name": "job_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "job_organization_id_organization_id_fk": {
+ "name": "job_organization_id_organization_id_fk",
+ "tableFrom": "job",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "job_slug_unique": {
+ "name": "job_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.job_question": {
+ "name": "job_question",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "question_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'short_text'"
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "required": {
+ "name": "required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "job_question_organization_id_idx": {
+ "name": "job_question_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "job_question_job_id_idx": {
+ "name": "job_question_job_id_idx",
+ "columns": [
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "job_question_organization_id_organization_id_fk": {
+ "name": "job_question_organization_id_organization_id_fk",
+ "tableFrom": "job_question",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "job_question_job_id_job_id_fk": {
+ "name": "job_question_job_id_job_id_fk",
+ "tableFrom": "job_question",
+ "tableTo": "job",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.join_request": {
+ "name": "join_request",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "join_request_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed_by_id": {
+ "name": "reviewed_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "join_request_organization_id_idx": {
+ "name": "join_request_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_request_user_id_idx": {
+ "name": "join_request_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_request_status_idx": {
+ "name": "join_request_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "join_request_user_id_user_id_fk": {
+ "name": "join_request_user_id_user_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "join_request_organization_id_organization_id_fk": {
+ "name": "join_request_organization_id_organization_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "join_request_reviewed_by_id_user_id_fk": {
+ "name": "join_request_reviewed_by_id_user_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.question_response": {
+ "name": "question_response",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "question_id": {
+ "name": "question_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "question_response_organization_id_idx": {
+ "name": "question_response_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "question_response_application_id_idx": {
+ "name": "question_response_application_id_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "question_response_question_id_idx": {
+ "name": "question_response_question_id_idx",
+ "columns": [
+ {
+ "expression": "question_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "question_response_organization_id_organization_id_fk": {
+ "name": "question_response_organization_id_organization_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "question_response_application_id_application_id_fk": {
+ "name": "question_response_application_id_application_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "application",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "question_response_question_id_job_question_id_fk": {
+ "name": "question_response_question_id_job_question_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "job_question",
+ "columnsFrom": [
+ "question_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.activity_action": {
+ "name": "activity_action",
+ "schema": "public",
+ "values": [
+ "created",
+ "updated",
+ "deleted",
+ "status_changed",
+ "comment_added",
+ "member_invited",
+ "member_removed",
+ "member_role_changed"
+ ]
+ },
+ "public.application_status": {
+ "name": "application_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "screening",
+ "interview",
+ "offer",
+ "hired",
+ "rejected"
+ ]
+ },
+ "public.comment_target": {
+ "name": "comment_target",
+ "schema": "public",
+ "values": [
+ "candidate",
+ "application",
+ "job"
+ ]
+ },
+ "public.document_type": {
+ "name": "document_type",
+ "schema": "public",
+ "values": [
+ "resume",
+ "cover_letter",
+ "other"
+ ]
+ },
+ "public.interview_status": {
+ "name": "interview_status",
+ "schema": "public",
+ "values": [
+ "scheduled",
+ "completed",
+ "cancelled",
+ "no_show"
+ ]
+ },
+ "public.interview_type": {
+ "name": "interview_type",
+ "schema": "public",
+ "values": [
+ "phone",
+ "video",
+ "in_person",
+ "panel",
+ "technical",
+ "take_home"
+ ]
+ },
+ "public.job_status": {
+ "name": "job_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "open",
+ "closed",
+ "archived"
+ ]
+ },
+ "public.job_type": {
+ "name": "job_type",
+ "schema": "public",
+ "values": [
+ "full_time",
+ "part_time",
+ "contract",
+ "internship"
+ ]
+ },
+ "public.join_request_status": {
+ "name": "join_request_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "approved",
+ "rejected"
+ ]
+ },
+ "public.question_type": {
+ "name": "question_type",
+ "schema": "public",
+ "values": [
+ "short_text",
+ "long_text",
+ "single_select",
+ "multi_select",
+ "number",
+ "date",
+ "url",
+ "checkbox",
+ "file_upload"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json
index 7976b453..92c94e40 100644
--- a/server/database/migrations/meta/_journal.json
+++ b/server/database/migrations/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1772615027489,
"tag": "0009_organic_gauntlet",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "7",
+ "when": 1773152393640,
+ "tag": "0010_glamorous_mattie_franklin",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts
index 5b8d5654..d891b0a2 100644
--- a/server/database/schema/app.ts
+++ b/server/database/schema/app.ts
@@ -214,6 +214,46 @@ export const joinRequest = pgTable('join_request', {
// Collaboration: Comments
// ─────────────────────────────────────────────
+// ─────────────────────────────────────────────
+// Interviews
+// ─────────────────────────────────────────────
+
+export const interviewTypeEnum = pgEnum('interview_type', [
+ 'phone', 'video', 'in_person', 'panel', 'technical', 'take_home',
+])
+
+export const interviewStatusEnum = pgEnum('interview_status', [
+ 'scheduled', 'completed', 'cancelled', 'no_show',
+])
+
+/**
+ * Interviews scheduled for applications in the pipeline.
+ * Each interview is linked to an application (which contains candidate + job).
+ * Multiple interviews can exist per application (e.g., phone screen → technical → panel).
+ */
+export const interview = pgTable('interview', {
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+ organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
+ applicationId: text('application_id').notNull().references(() => application.id, { onDelete: 'cascade' }),
+ title: text('title').notNull(),
+ type: interviewTypeEnum('type').notNull().default('video'),
+ status: interviewStatusEnum('status').notNull().default('scheduled'),
+ scheduledAt: timestamp('scheduled_at').notNull(),
+ duration: integer('duration').notNull().default(60),
+ location: text('location'),
+ notes: text('notes'),
+ interviewers: jsonb('interviewers').$type(),
+ createdById: text('created_by_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+}, (t) => ([
+ index('interview_organization_id_idx').on(t.organizationId),
+ index('interview_application_id_idx').on(t.applicationId),
+ index('interview_scheduled_at_idx').on(t.scheduledAt),
+ index('interview_status_idx').on(t.status),
+ index('interview_created_by_id_idx').on(t.createdById),
+]))
+
export const commentTargetEnum = pgEnum('comment_target', ['candidate', 'application', 'job'])
/**
@@ -285,6 +325,7 @@ export const applicationRelations = relations(application, ({ one, many }) => ({
candidate: one(candidate, { fields: [application.candidateId], references: [candidate.id] }),
job: one(job, { fields: [application.jobId], references: [job.id] }),
responses: many(questionResponse),
+ interviews: many(interview),
}))
export const documentRelations = relations(document, ({ one }) => ({
@@ -323,3 +364,9 @@ export const joinRequestRelations = relations(joinRequest, ({ one }) => ({
organization: one(organization, { fields: [joinRequest.organizationId], references: [organization.id] }),
reviewedBy: one(user, { fields: [joinRequest.reviewedById], references: [user.id] }),
}))
+
+export const interviewRelations = relations(interview, ({ one }) => ({
+ organization: one(organization, { fields: [interview.organizationId], references: [organization.id] }),
+ application: one(application, { fields: [interview.applicationId], references: [application.id] }),
+ createdBy: one(user, { fields: [interview.createdById], references: [user.id] }),
+}))
diff --git a/server/utils/schemas/interview.ts b/server/utils/schemas/interview.ts
new file mode 100644
index 00000000..b2172d81
--- /dev/null
+++ b/server/utils/schemas/interview.ts
@@ -0,0 +1,56 @@
+import { z } from 'zod'
+
+// ─────────────────────────────────────────────
+// Interview validation schemas — shared across API routes
+// ─────────────────────────────────────────────
+
+const interviewTypes = ['phone', 'video', 'in_person', 'panel', 'technical', 'take_home'] as const
+const interviewStatuses = ['scheduled', 'completed', 'cancelled', 'no_show'] as const
+
+/** Schema for creating a new interview */
+export const createInterviewSchema = z.object({
+ applicationId: z.string().min(1, 'Application is required'),
+ title: z.string().min(1, 'Title is required').max(200),
+ type: z.enum(interviewTypes).default('video'),
+ scheduledAt: z.string().datetime({ message: 'Valid ISO 8601 datetime required' }),
+ duration: z.number().int().min(5).max(480).default(60),
+ location: z.string().max(500).optional(),
+ notes: z.string().max(5000).optional(),
+ interviewers: z.array(z.string().max(200)).max(20).optional(),
+})
+
+/** Schema for updating an existing interview */
+export const updateInterviewSchema = z.object({
+ title: z.string().min(1).max(200).optional(),
+ type: z.enum(interviewTypes).optional(),
+ status: z.enum(interviewStatuses).optional(),
+ scheduledAt: z.string().datetime({ message: 'Valid ISO 8601 datetime required' }).optional(),
+ duration: z.number().int().min(5).max(480).optional(),
+ location: z.string().max(500).nullish(),
+ notes: z.string().max(5000).nullish(),
+ interviewers: z.array(z.string().max(200)).max(20).nullish(),
+})
+
+/** Schema for interview list query params */
+export const interviewQuerySchema = z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ limit: z.coerce.number().int().min(1).max(100).default(20),
+ applicationId: z.string().min(1).optional(),
+ jobId: z.string().min(1).optional(),
+ status: z.enum(interviewStatuses).optional(),
+ from: z.string().datetime().optional(),
+ to: z.string().datetime().optional(),
+})
+
+/** Reusable schema for `:id` route params */
+export const interviewIdParamSchema = z.object({
+ id: z.string().min(1),
+})
+
+/** Allowed status transitions for interviews */
+export const INTERVIEW_STATUS_TRANSITIONS: Record = {
+ scheduled: ['completed', 'cancelled', 'no_show'],
+ completed: [],
+ cancelled: ['scheduled'],
+ no_show: ['scheduled'],
+}
diff --git a/shared/permissions.ts b/shared/permissions.ts
index 24ba3942..fa5891dc 100644
--- a/shared/permissions.ts
+++ b/shared/permissions.ts
@@ -33,6 +33,7 @@ const atsStatements = {
application: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
+ interview: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
} as const
@@ -52,37 +53,34 @@ export const ac = createAccessControl(statements)
// member — recruiters. Read jobs, manage candidates/applications in pipeline.
export const owner = ac.newRole({
- // Inherit all default owner permissions (org:*, member:*, invitation:*)
...ownerAc.statements,
- // ATS permissions — full control
job: ['create', 'read', 'update', 'delete'],
candidate: ['create', 'read', 'update', 'delete'],
application: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
+ interview: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
})
export const admin = ac.newRole({
- // Inherit default admin permissions (no org:delete, no owner transfer)
...adminAc.statements,
- // ATS permissions — full CRUD
job: ['create', 'read', 'update', 'delete'],
candidate: ['create', 'read', 'update', 'delete'],
application: ['create', 'read', 'update', 'delete'],
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
+ interview: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
})
export const member = ac.newRole({
- // Inherit default member permissions (read-only on org data)
...memberAc.statements,
- // ATS permissions — read jobs, manage own pipeline
job: ['read'],
candidate: ['create', 'read', 'update'],
application: ['create', 'read', 'update'],
document: ['create', 'read'],
comment: ['create', 'read', 'delete'],
+ interview: ['create', 'read', 'update'],
activityLog: ['read'],
})
From 59bdb36d5ba33a280f20c39ceb9b9eb53fa2eeec Mon Sep 17 00:00:00 2001
From: Joachim
Date: Tue, 10 Mar 2026 15:45:58 +0100
Subject: [PATCH 02/36] feat: add interviews dashboard page with filtering,
editing, and deleting functionalities
---
app/pages/dashboard/interviews/[id].vue | 735 ++++++++++++++++++
.../{interviews.vue => interviews/index.vue} | 18 +-
2 files changed, 750 insertions(+), 3 deletions(-)
create mode 100644 app/pages/dashboard/interviews/[id].vue
rename app/pages/dashboard/{interviews.vue => interviews/index.vue} (97%)
diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue
new file mode 100644
index 00000000..9fe9bd69
--- /dev/null
+++ b/app/pages/dashboard/interviews/[id].vue
@@ -0,0 +1,735 @@
+
+
+
+
+
+
+
+ Back to Interviews
+
+
+
+
+
+
+
+ {{ (error as any).statusCode === 404 ? 'Interview not found.' : 'Failed to load interview.' }}
+ Back to Interviews
+
+
+
+
+
+
+
+
+
+ {{ getCandidateInitials(interview.candidateFirstName, interview.candidateLastName) }}
+
+
+
+
+ {{ interview.title }}
+
+
+
+ {{ statusConfig[interview.status as InterviewStatus]?.label }}
+
+
+
+
+
+ {{ interview.candidateFirstName }} {{ interview.candidateLastName }}
+
+
+
+ {{ interview.jobTitle }}
+
+
+
+
+
+
+
+
+
+
+
+ Quick actions
+
+
+
+
+
+
+
+
+
+
+
+
Schedule
+
+
+
+
- Date & Time
+ -
+ {{ formatDateTime(interview.scheduledAt) }}
+
+
+
+
- Duration
+ - {{ interview.duration }} minutes
+
+
+
- Type
+ -
+
+ {{ typeLabels[interview.type] ?? interview.type }}
+
+
+
+
- Location / Link
+ - {{ interview.location }}
+
+
+
+
+
+
+
+
+
Candidate
+
+
+
+
- Name
+ -
+ {{ interview.candidateFirstName }} {{ interview.candidateLastName }}
+
+
+
+
- Email
+ - {{ interview.candidateEmail }}
+
+
+
- Phone
+ - {{ interview.candidatePhone }}
+
+
+
- Job
+ - {{ interview.jobTitle }}
+
+
+
+
+
+
+
+
+
Interviewers
+
+
+
+
+ {{ interviewer }}
+
+
+
+
+
+
+
+
+
- Created
+ - {{ formatDate(interview.createdAt) }}
+
+
+
- Updated
+ - {{ formatDate(interview.updatedAt) }}
+
+
+
+
+
+
+
+
+
+
+
Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ interview.notes }}
+
+
No notes yet.
+
+
+
+
+
Danger Zone
+
Permanently delete this interview. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
Reschedule Interview
+
+
+ {{ rescheduleError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Interview Details
+
+
+ {{ editErrors.submit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Interview
+
+ Are you sure you want to delete {{ interview?.title }}? This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews.vue b/app/pages/dashboard/interviews/index.vue
similarity index 97%
rename from app/pages/dashboard/interviews.vue
rename to app/pages/dashboard/interviews/index.vue
index 6adef9fe..cbf68ae0 100644
--- a/app/pages/dashboard/interviews.vue
+++ b/app/pages/dashboard/interviews/index.vue
@@ -446,8 +446,13 @@ const statusCounts = computed(() => {
-
- {{ interviewItem.title }}
+
+
+ {{ interviewItem.title }}
+
{
{{ statusConfig[interviewItem.status]?.label }}
-
{{ interviewItem.title }}
+
+
+ {{ interviewItem.title }}
+
+
From 7879e38e2e54f7f3ac07d84faec3e36103ea0ded Mon Sep 17 00:00:00 2001
From: Joachim
Date: Tue, 10 Mar 2026 16:07:18 +0100
Subject: [PATCH 03/36] feat: add email template validation schemas and
pre-made templates
- Introduced `createEmailTemplateSchema` and `updateEmailTemplateSchema` for validating email template creation and updates.
- Added `sendInterviewInvitationSchema` to ensure either a template ID or both custom subject and body are provided.
- Defined a list of allowed placeholder variables for email templates.
- Created a set of pre-made system templates for interview invitations with various styles.
---
app/components/InterviewEmailModal.vue | 569 ++++
app/components/InterviewScheduleSidebar.vue | 28 +-
app/composables/useEmailTemplates.ts | 89 +
app/composables/useInterviews.ts | 1 +
app/pages/dashboard/interviews/[id].vue | 36 +-
.../api/email-templates/[id]/index.delete.ts | 39 +
.../api/email-templates/[id]/index.patch.ts | 42 +
server/api/email-templates/index.get.ts | 14 +
server/api/email-templates/index.post.ts | 31 +
server/api/interviews/[id]/index.get.ts | 1 +
.../interviews/[id]/send-invitation.post.ts | 156 +
server/api/interviews/index.get.ts | 1 +
.../migrations/0011_oval_enchantress.sql | 16 +
.../migrations/meta/0011_snapshot.json | 2627 +++++++++++++++++
server/database/migrations/meta/_journal.json | 7 +
server/database/schema/app.ts | 29 +
server/utils/email.ts | 137 +
server/utils/schemas/emailTemplate.ts | 142 +
shared/permissions.ts | 4 +
19 files changed, 3966 insertions(+), 3 deletions(-)
create mode 100644 app/components/InterviewEmailModal.vue
create mode 100644 app/composables/useEmailTemplates.ts
create mode 100644 server/api/email-templates/[id]/index.delete.ts
create mode 100644 server/api/email-templates/[id]/index.patch.ts
create mode 100644 server/api/email-templates/index.get.ts
create mode 100644 server/api/email-templates/index.post.ts
create mode 100644 server/api/interviews/[id]/send-invitation.post.ts
create mode 100644 server/database/migrations/0011_oval_enchantress.sql
create mode 100644 server/database/migrations/meta/0011_snapshot.json
create mode 100644 server/utils/schemas/emailTemplate.ts
diff --git a/app/components/InterviewEmailModal.vue b/app/components/InterviewEmailModal.vue
new file mode 100644
index 00000000..e7d6f030
--- /dev/null
+++ b/app/components/InterviewEmailModal.vue
@@ -0,0 +1,569 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Send Interview Invitation
+
+
+ to {{ interview.candidateFirstName }} {{ interview.candidateLastName }} · {{ interview.candidateEmail }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Invitation Sent!
+
+ The interview invitation has been sent to {{ interview.candidateEmail }}.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a template to send the interview invitation.
+
+
+
+
+
+
+
+
+
Subject
+
{{ previewSubject }}
+
+
+
Body
+
{{ previewBody }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Available Variables
+
+
+ {{ v.key }}
+
+
+
+
+
+
+
+
+
+
Subject
+
{{ previewSubject }}
+
+
+
Body
+
{{ previewBody }}
+
+
+
+
+
+
+
+
+
+ Create and manage reusable email templates for your organization.
+
+
+
+
+
+
+
Create Template
+
+ {{ templateSaveError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Your Templates
+
+
+
{{ t.name }}
+
{{ t.subject }}
+
+
+
+
+
+
+
No custom templates yet.
+
Create one to reuse across interview invitations.
+
+
+
+
+
Available Variables
+
+
+ {{ v.key }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/InterviewScheduleSidebar.vue b/app/components/InterviewScheduleSidebar.vue
index ea55337e..8bc27768 100644
--- a/app/components/InterviewScheduleSidebar.vue
+++ b/app/components/InterviewScheduleSidebar.vue
@@ -30,6 +30,7 @@ const form = reactive({
const errors = ref>({})
const isSubmitting = ref(false)
+const sendInvitationAfter = ref(false)
// Set a sensible default title
// Helper to extract YYYY-MM-DD from a Date object
@@ -199,7 +200,7 @@ async function handleSubmit() {
try {
const filteredInterviewers = form.interviewers.filter(i => i.trim())
- await $fetch('/api/interviews', {
+ const created = await $fetch('/api/interviews', {
method: 'POST',
body: {
applicationId: props.applicationId,
@@ -213,6 +214,19 @@ async function handleSubmit() {
},
})
+ // Optionally send invitation email immediately
+ if (sendInvitationAfter.value && created?.id) {
+ try {
+ await $fetch(`/api/interviews/${created.id}/send-invitation`, {
+ method: 'POST',
+ body: { templateId: 'system-standard' },
+ })
+ } catch {
+ // Interview was created successfully — don't block on email failure.
+ // The user can always resend from the interview detail page.
+ }
+ }
+
await refreshNuxtData('interviews')
emit('scheduled')
} catch (err: any) {
@@ -535,6 +549,18 @@ async function handleSubmit() {
{{ isSubmitting ? 'Scheduling…' : 'Schedule Interview' }}
+
+
+
diff --git a/app/composables/useEmailTemplates.ts b/app/composables/useEmailTemplates.ts
new file mode 100644
index 00000000..9b390a84
--- /dev/null
+++ b/app/composables/useEmailTemplates.ts
@@ -0,0 +1,89 @@
+export interface EmailTemplate {
+ id: string
+ name: string
+ subject: string
+ body: string
+ organizationId: string
+ createdById: string
+ createdAt: string
+ updatedAt: string
+}
+
+/**
+ * Composable for managing email templates (CRUD + system templates).
+ */
+export function useEmailTemplates() {
+ const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+
+ const { data: templates, status, error, refresh } = useFetch('/api/email-templates', {
+ key: 'email-templates',
+ headers: useRequestHeaders(['cookie']),
+ default: () => [],
+ })
+
+ async function createTemplate(payload: {
+ name: string
+ subject: string
+ body: string
+ }) {
+ try {
+ const created = await $fetch('/api/email-templates', {
+ method: 'POST',
+ body: payload,
+ })
+ await refresh()
+ return created
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ async function updateTemplate(id: string, payload: Partial<{
+ name: string
+ subject: string
+ body: string
+ }>) {
+ try {
+ const updated = await $fetch(`/api/email-templates/${id}`, {
+ method: 'PATCH',
+ body: payload,
+ })
+ await refresh()
+ return updated
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ async function deleteTemplate(id: string) {
+ try {
+ await $fetch(`/api/email-templates/${id}`, {
+ method: 'DELETE',
+ })
+ await refresh()
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ async function sendInvitation(interviewId: string, payload: {
+ templateId?: string
+ customSubject?: string
+ customBody?: string
+ }) {
+ try {
+ return await $fetch(`/api/interviews/${interviewId}/send-invitation`, {
+ method: 'POST',
+ body: payload,
+ })
+ } catch (error) {
+ handlePreviewReadOnlyError(error)
+ throw error
+ }
+ }
+
+ return { templates, status, error, refresh, createTemplate, updateTemplate, deleteTemplate, sendInvitation }
+}
diff --git a/app/composables/useInterviews.ts b/app/composables/useInterviews.ts
index 6c323048..c0a99496 100644
--- a/app/composables/useInterviews.ts
+++ b/app/composables/useInterviews.ts
@@ -10,6 +10,7 @@ export interface Interview {
location: string | null
notes: string | null
interviewers: string[] | null
+ invitationSentAt: string | null
applicationId: string
candidateFirstName: string
candidateLastName: string
diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue
index 9fe9bd69..5b67f466 100644
--- a/app/pages/dashboard/interviews/[id].vue
+++ b/app/pages/dashboard/interviews/[id].vue
@@ -3,7 +3,7 @@ import {
ArrowLeft, Calendar, Clock, Video, Phone, Building2, Code2,
FileText, UsersRound, CheckCircle2, XCircle, AlertTriangle,
UserRound, Briefcase, Pencil, MapPin, Users, MessageSquare,
- Save, X,
+ Save, X, Mail, Send, CheckCheck,
} from 'lucide-vue-next'
definePageMeta({
@@ -15,7 +15,7 @@ const route = useRoute()
const interviewId = route.params.id as string
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
-const { interview, status: fetchStatus, error, updateInterview, deleteInterview } = useInterview(interviewId)
+const { interview, status: fetchStatus, error, updateInterview, deleteInterview, refresh } = useInterview(interviewId)
useSeoMeta({
title: computed(() =>
@@ -269,6 +269,14 @@ async function handleDelete() {
isDeleting.value = false
}
}
+
+// ─── Email invitation ────────────────────────────────────────────
+const showEmailModal = ref(false)
+
+async function handleInvitationSent() {
+ showEmailModal.value = false
+ await refresh()
+}
@@ -343,6 +351,14 @@ async function handleDelete() {
+
+
+
+ Invitation sent {{ formatDate(interview.invitationSentAt) }}
+
@@ -373,6 +389,14 @@ async function handleDelete() {
Reschedule
+
@@ -731,5 +755,13 @@ async function handleDelete() {
+
+
+
diff --git a/server/api/email-templates/[id]/index.delete.ts b/server/api/email-templates/[id]/index.delete.ts
new file mode 100644
index 00000000..3172d2be
--- /dev/null
+++ b/server/api/email-templates/[id]/index.delete.ts
@@ -0,0 +1,39 @@
+import { and, eq } from 'drizzle-orm'
+import { emailTemplate } from '../../../database/schema'
+import { emailTemplateIdParamSchema } from '../../../utils/schemas/emailTemplate'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { emailTemplate: ['delete'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, emailTemplateIdParamSchema.parse)
+
+ // Verify the template exists and belongs to this org
+ const existing = await db.query.emailTemplate.findFirst({
+ where: and(
+ eq(emailTemplate.id, id),
+ eq(emailTemplate.organizationId, orgId),
+ ),
+ columns: { id: true },
+ })
+
+ if (!existing) {
+ throw createError({ statusCode: 404, statusMessage: 'Email template not found' })
+ }
+
+ await db.delete(emailTemplate).where(and(
+ eq(emailTemplate.id, id),
+ eq(emailTemplate.organizationId, orgId),
+ ))
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'deleted',
+ resourceType: 'emailTemplate',
+ resourceId: id,
+ })
+
+ setResponseStatus(event, 204)
+ return null
+})
diff --git a/server/api/email-templates/[id]/index.patch.ts b/server/api/email-templates/[id]/index.patch.ts
new file mode 100644
index 00000000..d1253d47
--- /dev/null
+++ b/server/api/email-templates/[id]/index.patch.ts
@@ -0,0 +1,42 @@
+import { and, eq } from 'drizzle-orm'
+import { emailTemplate } from '../../../database/schema'
+import { emailTemplateIdParamSchema, updateEmailTemplateSchema } from '../../../utils/schemas/emailTemplate'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { emailTemplate: ['update'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, emailTemplateIdParamSchema.parse)
+ const body = await readValidatedBody(event, updateEmailTemplateSchema.parse)
+
+ // Verify the template exists and belongs to this org
+ const existing = await db.query.emailTemplate.findFirst({
+ where: and(
+ eq(emailTemplate.id, id),
+ eq(emailTemplate.organizationId, orgId),
+ ),
+ columns: { id: true },
+ })
+
+ if (!existing) {
+ throw createError({ statusCode: 404, statusMessage: 'Email template not found' })
+ }
+
+ const [updated] = await db.update(emailTemplate)
+ .set({ ...body, updatedAt: new Date() })
+ .where(and(
+ eq(emailTemplate.id, id),
+ eq(emailTemplate.organizationId, orgId),
+ ))
+ .returning()
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'updated',
+ resourceType: 'emailTemplate',
+ resourceId: id,
+ })
+
+ return updated
+})
diff --git a/server/api/email-templates/index.get.ts b/server/api/email-templates/index.get.ts
new file mode 100644
index 00000000..1633a5dd
--- /dev/null
+++ b/server/api/email-templates/index.get.ts
@@ -0,0 +1,14 @@
+import { eq } from 'drizzle-orm'
+import { emailTemplate } from '../../database/schema'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { emailTemplate: ['read'] })
+ const orgId = session.session.activeOrganizationId
+
+ const templates = await db.query.emailTemplate.findMany({
+ where: eq(emailTemplate.organizationId, orgId),
+ orderBy: (t, { desc }) => [desc(t.createdAt)],
+ })
+
+ return templates
+})
diff --git a/server/api/email-templates/index.post.ts b/server/api/email-templates/index.post.ts
new file mode 100644
index 00000000..ca71b92d
--- /dev/null
+++ b/server/api/email-templates/index.post.ts
@@ -0,0 +1,31 @@
+import { emailTemplate } from '../../database/schema'
+import { createEmailTemplateSchema } from '../../utils/schemas/emailTemplate'
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { emailTemplate: ['create'] })
+ const orgId = session.session.activeOrganizationId
+
+ const body = await readValidatedBody(event, createEmailTemplateSchema.parse)
+
+ const [created] = await db.insert(emailTemplate).values({
+ organizationId: orgId,
+ name: body.name,
+ subject: body.subject,
+ body: body.body,
+ createdById: session.user.id,
+ }).returning()
+
+ if (!created) throw createError({ statusCode: 500, statusMessage: 'Failed to create email template' })
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'created',
+ resourceType: 'emailTemplate',
+ resourceId: created.id,
+ metadata: { name: body.name },
+ })
+
+ setResponseStatus(event, 201)
+ return created
+})
diff --git a/server/api/interviews/[id]/index.get.ts b/server/api/interviews/[id]/index.get.ts
index 07882280..6325680e 100644
--- a/server/api/interviews/[id]/index.get.ts
+++ b/server/api/interviews/[id]/index.get.ts
@@ -19,6 +19,7 @@ export default defineEventHandler(async (event) => {
location: interview.location,
notes: interview.notes,
interviewers: interview.interviewers,
+ invitationSentAt: interview.invitationSentAt,
createdById: interview.createdById,
createdAt: interview.createdAt,
updatedAt: interview.updatedAt,
diff --git a/server/api/interviews/[id]/send-invitation.post.ts b/server/api/interviews/[id]/send-invitation.post.ts
new file mode 100644
index 00000000..4c907f69
--- /dev/null
+++ b/server/api/interviews/[id]/send-invitation.post.ts
@@ -0,0 +1,156 @@
+import { and, eq } from 'drizzle-orm'
+import { interview, application, candidate, job, emailTemplate, organization } from '../../../database/schema'
+import { interviewIdParamSchema } from '../../../utils/schemas/interview'
+import { sendInterviewInvitationSchema, SYSTEM_TEMPLATES } from '../../../utils/schemas/emailTemplate'
+import { sendInterviewInvitationEmail, type InterviewEmailData } from '../../../utils/email'
+
+const interviewTypeLabels: Record = {
+ video: 'Video Call',
+ phone: 'Phone Call',
+ in_person: 'In Person',
+ technical: 'Technical Interview',
+ panel: 'Panel Interview',
+ take_home: 'Take-Home Assignment',
+}
+
+export default defineEventHandler(async (event) => {
+ const session = await requirePermission(event, { interview: ['update'] })
+ const orgId = session.session.activeOrganizationId
+
+ const { id } = await getValidatedRouterParams(event, interviewIdParamSchema.parse)
+ const body = await readValidatedBody(event, sendInterviewInvitationSchema.parse)
+
+ // Fetch interview with all related data
+ const interviewRecord = await db.query.interview.findFirst({
+ where: and(
+ eq(interview.id, id),
+ eq(interview.organizationId, orgId),
+ ),
+ })
+
+ if (!interviewRecord) {
+ throw createError({ statusCode: 404, statusMessage: 'Interview not found' })
+ }
+
+ if (interviewRecord.status === 'cancelled') {
+ throw createError({ statusCode: 400, statusMessage: 'Cannot send invitation for a cancelled interview' })
+ }
+
+ // Fetch application → candidate + job data
+ const app = await db.query.application.findFirst({
+ where: eq(application.id, interviewRecord.applicationId),
+ with: {
+ candidate: true,
+ job: { columns: { title: true } },
+ },
+ })
+
+ if (!app || !app.candidate) {
+ throw createError({ statusCode: 404, statusMessage: 'Application or candidate not found' })
+ }
+
+ // Fetch organization name
+ const org = await db.query.organization.findFirst({
+ where: eq(organization.id, orgId),
+ columns: { name: true },
+ })
+
+ if (!org) {
+ throw createError({ statusCode: 404, statusMessage: 'Organization not found' })
+ }
+
+ // Resolve template subject and body
+ let emailSubject: string
+ let emailBody: string
+
+ if (body.templateId) {
+ // Check system templates first
+ const systemTemplate = SYSTEM_TEMPLATES.find(t => t.id === body.templateId)
+ if (systemTemplate) {
+ emailSubject = systemTemplate.subject
+ emailBody = systemTemplate.body
+ } else {
+ // Look up custom template in database
+ const customTemplate = await db.query.emailTemplate.findFirst({
+ where: and(
+ eq(emailTemplate.id, body.templateId),
+ eq(emailTemplate.organizationId, orgId),
+ ),
+ })
+
+ if (!customTemplate) {
+ throw createError({ statusCode: 404, statusMessage: 'Email template not found' })
+ }
+
+ emailSubject = customTemplate.subject
+ emailBody = customTemplate.body
+ }
+ } else if (body.customSubject && body.customBody) {
+ emailSubject = body.customSubject
+ emailBody = body.customBody
+ } else {
+ throw createError({ statusCode: 400, statusMessage: 'Either a template or custom subject/body is required' })
+ }
+
+ // Build template data
+ const scheduledAt = new Date(interviewRecord.scheduledAt)
+ const emailData: InterviewEmailData = {
+ candidateName: `${app.candidate.firstName} ${app.candidate.lastName}`,
+ candidateFirstName: app.candidate.firstName,
+ candidateLastName: app.candidate.lastName,
+ candidateEmail: app.candidate.email,
+ jobTitle: app.job.title,
+ interviewTitle: interviewRecord.title,
+ interviewDate: scheduledAt.toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ }),
+ interviewTime: scheduledAt.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ }),
+ interviewDuration: interviewRecord.duration,
+ interviewType: interviewTypeLabels[interviewRecord.type] ?? interviewRecord.type,
+ interviewLocation: interviewRecord.location,
+ interviewers: interviewRecord.interviewers as string[] | null,
+ organizationName: org.name,
+ }
+
+ // Send the email
+ await sendInterviewInvitationEmail({
+ subject: emailSubject,
+ body: emailBody,
+ data: emailData,
+ })
+
+ // Mark the interview as invitation sent
+ const [updated] = await db.update(interview)
+ .set({ invitationSentAt: new Date(), updatedAt: new Date() })
+ .where(and(
+ eq(interview.id, id),
+ eq(interview.organizationId, orgId),
+ ))
+ .returning()
+
+ recordActivity({
+ organizationId: orgId,
+ actorId: session.user.id,
+ action: 'updated',
+ resourceType: 'interview',
+ resourceId: id,
+ metadata: {
+ action: 'invitation_sent',
+ candidateEmail: app.candidate.email,
+ templateId: body.templateId ?? 'custom',
+ },
+ })
+
+ return {
+ success: true,
+ sentAt: updated?.invitationSentAt,
+ candidateEmail: app.candidate.email,
+ }
+})
diff --git a/server/api/interviews/index.get.ts b/server/api/interviews/index.get.ts
index 2988fe09..e5aefbef 100644
--- a/server/api/interviews/index.get.ts
+++ b/server/api/interviews/index.get.ts
@@ -40,6 +40,7 @@ export default defineEventHandler(async (event) => {
location: interview.location,
notes: interview.notes,
interviewers: interview.interviewers,
+ invitationSentAt: interview.invitationSentAt,
createdAt: interview.createdAt,
updatedAt: interview.updatedAt,
applicationId: interview.applicationId,
diff --git a/server/database/migrations/0011_oval_enchantress.sql b/server/database/migrations/0011_oval_enchantress.sql
new file mode 100644
index 00000000..8a73f79c
--- /dev/null
+++ b/server/database/migrations/0011_oval_enchantress.sql
@@ -0,0 +1,16 @@
+CREATE TABLE "email_template" (
+ "id" text PRIMARY KEY NOT NULL,
+ "organization_id" text NOT NULL,
+ "name" text NOT NULL,
+ "subject" text NOT NULL,
+ "body" text NOT NULL,
+ "created_by_id" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "interview" ADD COLUMN "invitation_sent_at" timestamp;--> statement-breakpoint
+ALTER TABLE "email_template" ADD CONSTRAINT "email_template_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "email_template" ADD CONSTRAINT "email_template_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "email_template_organization_id_idx" ON "email_template" USING btree ("organization_id");--> statement-breakpoint
+CREATE INDEX "email_template_created_by_id_idx" ON "email_template" USING btree ("created_by_id");
\ No newline at end of file
diff --git a/server/database/migrations/meta/0011_snapshot.json b/server/database/migrations/meta/0011_snapshot.json
new file mode 100644
index 00000000..b021d269
--- /dev/null
+++ b/server/database/migrations/meta/0011_snapshot.json
@@ -0,0 +1,2627 @@
+{
+ "id": "f1eab5d3-0996-414d-b0d3-ec18cc6cb08c",
+ "prevId": "aa26921f-7e41-4926-9cd4-b4b9f70fa220",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invitation": {
+ "name": "invitation",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inviter_id": {
+ "name": "inviter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invitation_organization_id_idx": {
+ "name": "invitation_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invitation_email_idx": {
+ "name": "invitation_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invitation_inviter_id_user_id_fk": {
+ "name": "invitation_inviter_id_user_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "user",
+ "columnsFrom": [
+ "inviter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invitation_organization_id_organization_id_fk": {
+ "name": "invitation_organization_id_organization_id_fk",
+ "tableFrom": "invitation",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.member": {
+ "name": "member",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "member_user_id_idx": {
+ "name": "member_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_organization_id_idx": {
+ "name": "member_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "member_user_org_unique_idx": {
+ "name": "member_user_org_unique_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "member_user_id_user_id_fk": {
+ "name": "member_user_id_user_id_fk",
+ "tableFrom": "member",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "member_organization_id_organization_id_fk": {
+ "name": "member_organization_id_organization_id_fk",
+ "tableFrom": "member",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organization": {
+ "name": "organization",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organization_slug_unique": {
+ "name": "organization_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "active_organization_id": {
+ "name": "active_organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.activity_log": {
+ "name": "activity_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "activity_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_type": {
+ "name": "resource_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "activity_log_organization_id_idx": {
+ "name": "activity_log_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_actor_id_idx": {
+ "name": "activity_log_actor_id_idx",
+ "columns": [
+ {
+ "expression": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_resource_idx": {
+ "name": "activity_log_resource_idx",
+ "columns": [
+ {
+ "expression": "resource_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_created_at_idx": {
+ "name": "activity_log_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "activity_log_organization_id_organization_id_fk": {
+ "name": "activity_log_organization_id_organization_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "activity_log_actor_id_user_id_fk": {
+ "name": "activity_log_actor_id_user_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "user",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.application": {
+ "name": "application",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_id": {
+ "name": "candidate_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "application_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'new'"
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_letter_text": {
+ "name": "cover_letter_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "application_organization_id_idx": {
+ "name": "application_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_candidate_id_idx": {
+ "name": "application_candidate_id_idx",
+ "columns": [
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_job_id_idx": {
+ "name": "application_job_id_idx",
+ "columns": [
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "application_org_candidate_job_idx": {
+ "name": "application_org_candidate_job_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "application_organization_id_organization_id_fk": {
+ "name": "application_organization_id_organization_id_fk",
+ "tableFrom": "application",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_candidate_id_candidate_id_fk": {
+ "name": "application_candidate_id_candidate_id_fk",
+ "tableFrom": "application",
+ "tableTo": "candidate",
+ "columnsFrom": [
+ "candidate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "application_job_id_job_id_fk": {
+ "name": "application_job_id_job_id_fk",
+ "tableFrom": "application",
+ "tableTo": "job",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.candidate": {
+ "name": "candidate",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "candidate_organization_id_idx": {
+ "name": "candidate_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "candidate_org_email_idx": {
+ "name": "candidate_org_email_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "candidate_organization_id_organization_id_fk": {
+ "name": "candidate_organization_id_organization_id_fk",
+ "tableFrom": "candidate",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comment": {
+ "name": "comment",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "comment_target",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comment_organization_id_idx": {
+ "name": "comment_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comment_target_idx": {
+ "name": "comment_target_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comment_author_id_idx": {
+ "name": "comment_author_id_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comment_organization_id_organization_id_fk": {
+ "name": "comment_organization_id_organization_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comment_author_id_user_id_fk": {
+ "name": "comment_author_id_user_id_fk",
+ "tableFrom": "comment",
+ "tableTo": "user",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document": {
+ "name": "document",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "candidate_id": {
+ "name": "candidate_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "document_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'resume'"
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parsed_content": {
+ "name": "parsed_content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_organization_id_idx": {
+ "name": "document_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_candidate_id_idx": {
+ "name": "document_candidate_id_idx",
+ "columns": [
+ {
+ "expression": "candidate_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_organization_id_organization_id_fk": {
+ "name": "document_organization_id_organization_id_fk",
+ "tableFrom": "document",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_candidate_id_candidate_id_fk": {
+ "name": "document_candidate_id_candidate_id_fk",
+ "tableFrom": "document",
+ "tableTo": "candidate",
+ "columnsFrom": [
+ "candidate_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "document_storage_key_unique": {
+ "name": "document_storage_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "storage_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_template": {
+ "name": "email_template",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_template_organization_id_idx": {
+ "name": "email_template_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_template_created_by_id_idx": {
+ "name": "email_template_created_by_id_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_template_organization_id_organization_id_fk": {
+ "name": "email_template_organization_id_organization_id_fk",
+ "tableFrom": "email_template",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_template_created_by_id_user_id_fk": {
+ "name": "email_template_created_by_id_user_id_fk",
+ "tableFrom": "email_template",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.interview": {
+ "name": "interview",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "interview_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'video'"
+ },
+ "status": {
+ "name": "status",
+ "type": "interview_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'scheduled'"
+ },
+ "scheduled_at": {
+ "name": "scheduled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interviewers": {
+ "name": "interviewers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invitation_sent_at": {
+ "name": "invitation_sent_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "interview_organization_id_idx": {
+ "name": "interview_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_application_id_idx": {
+ "name": "interview_application_id_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_scheduled_at_idx": {
+ "name": "interview_scheduled_at_idx",
+ "columns": [
+ {
+ "expression": "scheduled_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_status_idx": {
+ "name": "interview_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "interview_created_by_id_idx": {
+ "name": "interview_created_by_id_idx",
+ "columns": [
+ {
+ "expression": "created_by_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "interview_organization_id_organization_id_fk": {
+ "name": "interview_organization_id_organization_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "interview_application_id_application_id_fk": {
+ "name": "interview_application_id_application_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "application",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "interview_created_by_id_user_id_fk": {
+ "name": "interview_created_by_id_user_id_fk",
+ "tableFrom": "interview",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite_link": {
+ "name": "invite_link",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invite_link_organization_id_idx": {
+ "name": "invite_link_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invite_link_token_idx": {
+ "name": "invite_link_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invite_link_organization_id_organization_id_fk": {
+ "name": "invite_link_organization_id_organization_id_fk",
+ "tableFrom": "invite_link",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "invite_link_created_by_id_user_id_fk": {
+ "name": "invite_link_created_by_id_user_id_fk",
+ "tableFrom": "invite_link",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_link_token_unique": {
+ "name": "invite_link_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.job": {
+ "name": "job",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "job_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'full_time'"
+ },
+ "status": {
+ "name": "status",
+ "type": "job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "salary_min": {
+ "name": "salary_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_max": {
+ "name": "salary_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_currency": {
+ "name": "salary_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_unit": {
+ "name": "salary_unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_status": {
+ "name": "remote_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "valid_through": {
+ "name": "valid_through",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_resume": {
+ "name": "require_resume",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "require_cover_letter": {
+ "name": "require_cover_letter",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "job_organization_id_idx": {
+ "name": "job_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "job_organization_id_organization_id_fk": {
+ "name": "job_organization_id_organization_id_fk",
+ "tableFrom": "job",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "job_slug_unique": {
+ "name": "job_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.job_question": {
+ "name": "job_question",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "question_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'short_text'"
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "required": {
+ "name": "required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "job_question_organization_id_idx": {
+ "name": "job_question_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "job_question_job_id_idx": {
+ "name": "job_question_job_id_idx",
+ "columns": [
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "job_question_organization_id_organization_id_fk": {
+ "name": "job_question_organization_id_organization_id_fk",
+ "tableFrom": "job_question",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "job_question_job_id_job_id_fk": {
+ "name": "job_question_job_id_job_id_fk",
+ "tableFrom": "job_question",
+ "tableTo": "job",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.join_request": {
+ "name": "join_request",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "join_request_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed_by_id": {
+ "name": "reviewed_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "join_request_organization_id_idx": {
+ "name": "join_request_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_request_user_id_idx": {
+ "name": "join_request_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_request_status_idx": {
+ "name": "join_request_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "join_request_user_id_user_id_fk": {
+ "name": "join_request_user_id_user_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "join_request_organization_id_organization_id_fk": {
+ "name": "join_request_organization_id_organization_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "join_request_reviewed_by_id_user_id_fk": {
+ "name": "join_request_reviewed_by_id_user_id_fk",
+ "tableFrom": "join_request",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.question_response": {
+ "name": "question_response",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "question_id": {
+ "name": "question_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "question_response_organization_id_idx": {
+ "name": "question_response_organization_id_idx",
+ "columns": [
+ {
+ "expression": "organization_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "question_response_application_id_idx": {
+ "name": "question_response_application_id_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "question_response_question_id_idx": {
+ "name": "question_response_question_id_idx",
+ "columns": [
+ {
+ "expression": "question_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "question_response_organization_id_organization_id_fk": {
+ "name": "question_response_organization_id_organization_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "organization",
+ "columnsFrom": [
+ "organization_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "question_response_application_id_application_id_fk": {
+ "name": "question_response_application_id_application_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "application",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "question_response_question_id_job_question_id_fk": {
+ "name": "question_response_question_id_job_question_id_fk",
+ "tableFrom": "question_response",
+ "tableTo": "job_question",
+ "columnsFrom": [
+ "question_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.activity_action": {
+ "name": "activity_action",
+ "schema": "public",
+ "values": [
+ "created",
+ "updated",
+ "deleted",
+ "status_changed",
+ "comment_added",
+ "member_invited",
+ "member_removed",
+ "member_role_changed"
+ ]
+ },
+ "public.application_status": {
+ "name": "application_status",
+ "schema": "public",
+ "values": [
+ "new",
+ "screening",
+ "interview",
+ "offer",
+ "hired",
+ "rejected"
+ ]
+ },
+ "public.comment_target": {
+ "name": "comment_target",
+ "schema": "public",
+ "values": [
+ "candidate",
+ "application",
+ "job"
+ ]
+ },
+ "public.document_type": {
+ "name": "document_type",
+ "schema": "public",
+ "values": [
+ "resume",
+ "cover_letter",
+ "other"
+ ]
+ },
+ "public.interview_status": {
+ "name": "interview_status",
+ "schema": "public",
+ "values": [
+ "scheduled",
+ "completed",
+ "cancelled",
+ "no_show"
+ ]
+ },
+ "public.interview_type": {
+ "name": "interview_type",
+ "schema": "public",
+ "values": [
+ "phone",
+ "video",
+ "in_person",
+ "panel",
+ "technical",
+ "take_home"
+ ]
+ },
+ "public.job_status": {
+ "name": "job_status",
+ "schema": "public",
+ "values": [
+ "draft",
+ "open",
+ "closed",
+ "archived"
+ ]
+ },
+ "public.job_type": {
+ "name": "job_type",
+ "schema": "public",
+ "values": [
+ "full_time",
+ "part_time",
+ "contract",
+ "internship"
+ ]
+ },
+ "public.join_request_status": {
+ "name": "join_request_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "approved",
+ "rejected"
+ ]
+ },
+ "public.question_type": {
+ "name": "question_type",
+ "schema": "public",
+ "values": [
+ "short_text",
+ "long_text",
+ "single_select",
+ "multi_select",
+ "number",
+ "date",
+ "url",
+ "checkbox",
+ "file_upload"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json
index 92c94e40..49881bfd 100644
--- a/server/database/migrations/meta/_journal.json
+++ b/server/database/migrations/meta/_journal.json
@@ -78,6 +78,13 @@
"when": 1773152393640,
"tag": "0010_glamorous_mattie_franklin",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1773154463252,
+ "tag": "0011_oval_enchantress",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts
index d891b0a2..7d416928 100644
--- a/server/database/schema/app.ts
+++ b/server/database/schema/app.ts
@@ -244,6 +244,7 @@ export const interview = pgTable('interview', {
notes: text('notes'),
interviewers: jsonb('interviewers').$type(),
createdById: text('created_by_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
+ invitationSentAt: timestamp('invitation_sent_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (t) => ([
@@ -254,6 +255,29 @@ export const interview = pgTable('interview', {
index('interview_created_by_id_idx').on(t.createdById),
]))
+// ─────────────────────────────────────────────
+// Email Templates
+// ─────────────────────────────────────────────
+
+/**
+ * Reusable email templates for interview invitations.
+ * Each org can create custom templates or use the system defaults.
+ * Template body supports placeholder variables like {{candidateName}}, {{jobTitle}}, etc.
+ */
+export const emailTemplate = pgTable('email_template', {
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
+ organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
+ name: text('name').notNull(),
+ subject: text('subject').notNull(),
+ body: text('body').notNull(),
+ createdById: text('created_by_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+}, (t) => ([
+ index('email_template_organization_id_idx').on(t.organizationId),
+ index('email_template_created_by_id_idx').on(t.createdById),
+]))
+
export const commentTargetEnum = pgEnum('comment_target', ['candidate', 'application', 'job'])
/**
@@ -370,3 +394,8 @@ export const interviewRelations = relations(interview, ({ one }) => ({
application: one(application, { fields: [interview.applicationId], references: [application.id] }),
createdBy: one(user, { fields: [interview.createdById], references: [user.id] }),
}))
+
+export const emailTemplateRelations = relations(emailTemplate, ({ one }) => ({
+ organization: one(organization, { fields: [emailTemplate.organizationId], references: [organization.id] }),
+ createdBy: one(user, { fields: [emailTemplate.createdById], references: [user.id] }),
+}))
diff --git a/server/utils/email.ts b/server/utils/email.ts
index 3157ef9d..b127bab3 100644
--- a/server/utils/email.ts
+++ b/server/utils/email.ts
@@ -182,3 +182,140 @@ function escapeHtml(str: string): string {
.replace(/"/g, '"')
.replace(/'/g, ''')
}
+
+// ─────────────────────────────────────────────
+// Interview invitation emails
+// ─────────────────────────────────────────────
+
+export interface InterviewEmailData {
+ candidateName: string
+ candidateFirstName: string
+ candidateLastName: string
+ candidateEmail: string
+ jobTitle: string
+ interviewTitle: string
+ interviewDate: string
+ interviewTime: string
+ interviewDuration: number
+ interviewType: string
+ interviewLocation: string | null
+ interviewers: string[] | null
+ organizationName: string
+}
+
+/**
+ * Replace {{variable}} placeholders in a template string with actual values.
+ * Only replaces known variables to prevent injection of unexpected content.
+ */
+export function renderTemplate(template: string, data: InterviewEmailData): string {
+ const variables: Record = {
+ candidateName: data.candidateName,
+ candidateFirstName: data.candidateFirstName,
+ candidateLastName: data.candidateLastName,
+ candidateEmail: data.candidateEmail,
+ jobTitle: data.jobTitle,
+ interviewTitle: data.interviewTitle,
+ interviewDate: data.interviewDate,
+ interviewTime: data.interviewTime,
+ interviewDuration: String(data.interviewDuration),
+ interviewType: data.interviewType,
+ interviewLocation: data.interviewLocation ?? 'To be confirmed',
+ interviewers: data.interviewers?.join(', ') ?? 'To be confirmed',
+ organizationName: data.organizationName,
+ }
+
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
+ return key in variables ? variables[key]! : match
+ })
+}
+
+/**
+ * Send an interview invitation email to a candidate via Resend.
+ * Falls back to console.info when RESEND_API_KEY is not set.
+ */
+export async function sendInterviewInvitationEmail(params: {
+ subject: string
+ body: string
+ data: InterviewEmailData
+}): Promise {
+ const renderedSubject = renderTemplate(params.subject, params.data)
+ const renderedBody = renderTemplate(params.body, params.data)
+
+ const resend = getResendClient()
+
+ if (!resend) {
+ console.info(
+ `[Reqcore] Interview invitation email → ${params.data.candidateEmail} | ` +
+ `Subject: ${renderedSubject} | ` +
+ `Interview: ${params.data.interviewTitle} | ` +
+ `Date: ${params.data.interviewDate} at ${params.data.interviewTime}`,
+ )
+ return
+ }
+
+ const fromEmail = env.RESEND_FROM_EMAIL
+
+ const { error } = await resend.emails.send({
+ from: fromEmail,
+ to: [params.data.candidateEmail],
+ subject: renderedSubject,
+ html: buildInterviewInvitationHtml(renderedSubject, renderedBody, params.data),
+ text: renderedBody,
+ tags: [
+ { name: 'category', value: 'interview-invitation' },
+ { name: 'interview', value: params.data.interviewTitle.slice(0, 256).replace(/[^a-zA-Z0-9_-]/g, '_') },
+ ],
+ })
+
+ if (error) {
+ console.error('[Reqcore] Failed to send interview invitation email via Resend:', error)
+ throw new Error(`Failed to send interview invitation email: ${error.message}`)
+ }
+
+ console.info(`[Reqcore] Interview invitation email sent to ${params.data.candidateEmail} via Resend`)
+}
+
+function buildInterviewInvitationHtml(subject: string, bodyText: string, data: InterviewEmailData): string {
+ const bodyHtml = escapeHtml(bodyText).replace(/\n/g, '
')
+
+ return `
+
+
+
+
+ ${escapeHtml(subject)}
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(data.organizationName)}
+ |
+
+
+
+ |
+
+ ${bodyHtml}
+
+ |
+
+
+
+ |
+
+ Sent by ${escapeHtml(data.organizationName)} via Reqcore
+
+ |
+
+
+ |
+
+
+
+`
+}
diff --git a/server/utils/schemas/emailTemplate.ts b/server/utils/schemas/emailTemplate.ts
new file mode 100644
index 00000000..cc605c4a
--- /dev/null
+++ b/server/utils/schemas/emailTemplate.ts
@@ -0,0 +1,142 @@
+import { z } from 'zod'
+
+// ─────────────────────────────────────────────
+// Email template validation schemas
+// ─────────────────────────────────────────────
+
+/** Allowed placeholder variables for interview invitation templates */
+export const TEMPLATE_VARIABLES = [
+ 'candidateName',
+ 'candidateFirstName',
+ 'candidateLastName',
+ 'candidateEmail',
+ 'jobTitle',
+ 'interviewTitle',
+ 'interviewDate',
+ 'interviewTime',
+ 'interviewDuration',
+ 'interviewType',
+ 'interviewLocation',
+ 'interviewers',
+ 'organizationName',
+] as const
+
+const MAX_SUBJECT_LENGTH = 200
+const MAX_BODY_LENGTH = 10_000
+const MAX_NAME_LENGTH = 100
+
+/** Schema for creating a new email template */
+export const createEmailTemplateSchema = z.object({
+ name: z.string().min(1, 'Template name is required').max(MAX_NAME_LENGTH),
+ subject: z.string().min(1, 'Subject line is required').max(MAX_SUBJECT_LENGTH),
+ body: z.string().min(1, 'Email body is required').max(MAX_BODY_LENGTH),
+})
+
+/** Schema for updating an email template */
+export const updateEmailTemplateSchema = z.object({
+ name: z.string().min(1).max(MAX_NAME_LENGTH).optional(),
+ subject: z.string().min(1).max(MAX_SUBJECT_LENGTH).optional(),
+ body: z.string().min(1).max(MAX_BODY_LENGTH).optional(),
+})
+
+/** Schema for :id route params */
+export const emailTemplateIdParamSchema = z.object({
+ id: z.string().min(1),
+})
+
+/** Schema for sending an interview invitation */
+export const sendInterviewInvitationSchema = z.object({
+ templateId: z.string().min(1).optional(),
+ customSubject: z.string().min(1).max(MAX_SUBJECT_LENGTH).optional(),
+ customBody: z.string().min(1).max(MAX_BODY_LENGTH).optional(),
+}).refine(
+ data => data.templateId || (data.customSubject && data.customBody),
+ { message: 'Either a template ID or both custom subject and body are required' },
+)
+
+// ─────────────────────────────────────────────
+// Pre-made (system) templates
+// ─────────────────────────────────────────────
+
+export interface SystemTemplate {
+ id: string
+ name: string
+ subject: string
+ body: string
+}
+
+export const SYSTEM_TEMPLATES: SystemTemplate[] = [
+ {
+ id: 'system-standard',
+ name: 'Standard Interview Invitation',
+ subject: 'Interview Invitation: {{jobTitle}} at {{organizationName}}',
+ body: `Dear {{candidateName}},
+
+We are pleased to invite you to an interview for the {{jobTitle}} position at {{organizationName}}.
+
+Interview Details:
+- Date: {{interviewDate}}
+- Time: {{interviewTime}}
+- Duration: {{interviewDuration}} minutes
+- Type: {{interviewType}}
+- Location: {{interviewLocation}}
+
+Interviewers: {{interviewers}}
+
+Please confirm your availability by replying to this email. If you need to reschedule, let us know as soon as possible.
+
+We look forward to speaking with you!
+
+Best regards,
+{{organizationName}}`,
+ },
+ {
+ id: 'system-friendly',
+ name: 'Friendly & Casual',
+ subject: "Let's chat! Interview for {{jobTitle}}",
+ body: `Hi {{candidateFirstName}},
+
+Great news — we'd love to meet you for the {{jobTitle}} role at {{organizationName}}!
+
+Here are the details:
+- When: {{interviewDate}} at {{interviewTime}} ({{interviewDuration}} min)
+- How: {{interviewType}}
+- Where: {{interviewLocation}}
+
+You'll be speaking with: {{interviewers}}
+
+If this time doesn't work for you, just let us know and we'll find something that does.
+
+Looking forward to it!
+
+The {{organizationName}} Team`,
+ },
+ {
+ id: 'system-technical',
+ name: 'Technical Interview',
+ subject: 'Technical Interview: {{jobTitle}} — {{organizationName}}',
+ body: `Dear {{candidateName}},
+
+Thank you for your interest in the {{jobTitle}} position at {{organizationName}}. We'd like to invite you to a technical interview.
+
+Interview Details:
+- Title: {{interviewTitle}}
+- Date: {{interviewDate}}
+- Time: {{interviewTime}}
+- Duration: {{interviewDuration}} minutes
+- Format: {{interviewType}}
+- Location: {{interviewLocation}}
+
+Your interviewer(s): {{interviewers}}
+
+To help you prepare:
+- Be ready to discuss your technical experience and problem-solving approach
+- You may be asked to write or review code during the session
+- Feel free to ask questions about our tech stack and development practices
+
+Please confirm your attendance by replying to this email.
+
+Best regards,
+{{organizationName}}`,
+ },
+]
diff --git a/shared/permissions.ts b/shared/permissions.ts
index fa5891dc..37a788b6 100644
--- a/shared/permissions.ts
+++ b/shared/permissions.ts
@@ -34,6 +34,7 @@ const atsStatements = {
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
interview: ['create', 'read', 'update', 'delete'],
+ emailTemplate: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
} as const
@@ -60,6 +61,7 @@ export const owner = ac.newRole({
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
interview: ['create', 'read', 'update', 'delete'],
+ emailTemplate: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
})
@@ -71,6 +73,7 @@ export const admin = ac.newRole({
document: ['create', 'read', 'delete'],
comment: ['create', 'read', 'update', 'delete'],
interview: ['create', 'read', 'update', 'delete'],
+ emailTemplate: ['create', 'read', 'update', 'delete'],
activityLog: ['read'],
})
@@ -82,5 +85,6 @@ export const member = ac.newRole({
document: ['create', 'read'],
comment: ['create', 'read', 'delete'],
interview: ['create', 'read', 'update'],
+ emailTemplate: ['create', 'read', 'update'],
activityLog: ['read'],
})
From 616ada516992a2fd7c33b941b7b12f7a6b5467c0 Mon Sep 17 00:00:00 2001
From: Joachim
Date: Tue, 10 Mar 2026 16:23:23 +0100
Subject: [PATCH 04/36] feat: add email template management system
- Implemented a new dashboard for managing email templates, including listing, creating, editing, and deleting templates.
- Added a dedicated page for viewing and editing individual email templates.
- Introduced a new page for creating new email templates with a live preview feature.
- Created a utility for system templates and available variables for dynamic content in emails.
- Enhanced user experience with loading states, error handling, and confirmation dialogs for deletions.
---
app/components/InterviewEmailModal.vue | 2 +-
app/pages/dashboard/interviews/[id].vue | 227 ++++++++++-
app/pages/dashboard/interviews/index.vue | 8 +
.../dashboard/interviews/templates/[id].vue | 377 ++++++++++++++++++
.../dashboard/interviews/templates/index.vue | 247 ++++++++++++
.../dashboard/interviews/templates/new.vue | 242 +++++++++++
app/utils/system-templates.ts | 108 +++++
7 files changed, 1196 insertions(+), 15 deletions(-)
create mode 100644 app/pages/dashboard/interviews/templates/[id].vue
create mode 100644 app/pages/dashboard/interviews/templates/index.vue
create mode 100644 app/pages/dashboard/interviews/templates/new.vue
create mode 100644 app/utils/system-templates.ts
diff --git a/app/components/InterviewEmailModal.vue b/app/components/InterviewEmailModal.vue
index e7d6f030..3a3750b3 100644
--- a/app/components/InterviewEmailModal.vue
+++ b/app/components/InterviewEmailModal.vue
@@ -150,7 +150,7 @@ const previewVariables: Record = {
function renderPreview(template: string): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
- return key in previewVariables ? previewVariables[key]! : match
+ return key in previewVariables ? previewVariables[key] : match
})
}
diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue
index 5b67f466..75df88c3 100644
--- a/app/pages/dashboard/interviews/[id].vue
+++ b/app/pages/dashboard/interviews/[id].vue
@@ -3,7 +3,8 @@ import {
ArrowLeft, Calendar, Clock, Video, Phone, Building2, Code2,
FileText, UsersRound, CheckCircle2, XCircle, AlertTriangle,
UserRound, Briefcase, Pencil, MapPin, Users, MessageSquare,
- Save, X, Mail, Send, CheckCheck,
+ Save, X, Mail, Send, CheckCheck, ChevronDown, ExternalLink,
+ Check, AlertCircle,
} from 'lucide-vue-next'
definePageMeta({
@@ -270,13 +271,79 @@ async function handleDelete() {
}
}
-// ─── Email invitation ────────────────────────────────────────────
-const showEmailModal = ref(false)
+// ─── Email invitation (inline) ───────────────────────────────────
+const showSendInvitation = ref(false)
+const selectedTemplateId = ref('system-standard')
+const isSendingEmail = ref(false)
+const sendEmailError = ref('')
+const sendEmailSuccess = ref(false)
+const showEmailPreview = ref(false)
+
+const { templates: emailTemplates, sendInvitation } = useEmailTemplates()
+
+const allTemplates = computed(() => [
+ ...SYSTEM_TEMPLATES.map(t => ({ ...t, isSystem: true as const })),
+ ...(emailTemplates.value ?? []).map(t => ({ ...t, isSystem: false as const, description: '' })),
+])
+
+const selectedTemplate = computed(() =>
+ allTemplates.value.find(t => t.id === selectedTemplateId.value),
+)
+
+const emailPreviewVariables = computed(() => {
+ if (!interview.value) return {} as Record
+ return {
+ candidateName: `${interview.value.candidateFirstName} ${interview.value.candidateLastName}`,
+ candidateFirstName: interview.value.candidateFirstName,
+ candidateLastName: interview.value.candidateLastName,
+ candidateEmail: interview.value.candidateEmail,
+ jobTitle: interview.value.jobTitle,
+ interviewTitle: interview.value.title,
+ interviewDate: new Date(interview.value.scheduledAt).toLocaleDateString('en-US', {
+ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
+ }),
+ interviewTime: new Date(interview.value.scheduledAt).toLocaleTimeString('en-US', {
+ hour: 'numeric', minute: '2-digit', hour12: true,
+ }),
+ interviewDuration: String(interview.value.duration),
+ interviewType: ({
+ video: 'Video Call', phone: 'Phone Call', in_person: 'In Person',
+ technical: 'Technical Interview', panel: 'Panel Interview', take_home: 'Take-Home Assignment',
+ } as Record)[interview.value.type] ?? interview.value.type,
+ interviewLocation: interview.value.location ?? 'To be confirmed',
+ interviewers: interview.value.interviewers?.join(', ') ?? 'To be confirmed',
+ organizationName: 'Your Organization',
+ }
+})
+
+const emailPreviewSubject = computed(() =>
+ selectedTemplate.value ? renderTemplatePreview(selectedTemplate.value.subject, emailPreviewVariables.value) : '',
+)
+
+const emailPreviewBody = computed(() =>
+ selectedTemplate.value ? renderTemplatePreview(selectedTemplate.value.body, emailPreviewVariables.value) : '',
+)
-async function handleInvitationSent() {
- showEmailModal.value = false
- await refresh()
+async function handleSendInvitation() {
+ sendEmailError.value = ''
+ isSendingEmail.value = true
+ try {
+ await sendInvitation(interviewId, { templateId: selectedTemplateId.value })
+ sendEmailSuccess.value = true
+ setTimeout(async () => {
+ sendEmailSuccess.value = false
+ showSendInvitation.value = false
+ await refresh()
+ }, 2000)
+ } catch (err: any) {
+ if (handlePreviewReadOnlyError(err)) return
+ sendEmailError.value = err?.data?.statusMessage ?? err?.message ?? 'Failed to send invitation'
+ } finally {
+ isSendingEmail.value = false
+ }
}
+
+const localePath = useLocalePath()
@@ -392,14 +459,153 @@ async function handleInvitationSent() {
+
+
+
+
+
+
+
+
+
Invitation Sent!
+
Email sent to {{ interview.candidateEmail }}
+
+
+
+
+
+
+
+
+
+
+
+
Send Interview Invitation
+
to {{ interview.candidateEmail }}
+
+
+
+
+
+
+
+
+
+ {{ sendEmailError }}
+
+
+
+
+
+
Choose a Template
+
+ Manage Templates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
{{ emailPreviewSubject }}
+
+
+
Body
+
{{ emailPreviewBody }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -756,12 +962,5 @@ async function handleInvitationSent() {
-
-
diff --git a/app/pages/dashboard/interviews/index.vue b/app/pages/dashboard/interviews/index.vue
index cbf68ae0..971a7523 100644
--- a/app/pages/dashboard/interviews/index.vue
+++ b/app/pages/dashboard/interviews/index.vue
@@ -4,6 +4,7 @@ import {
Building2, Code2, FileText, UsersRound, MoreHorizontal,
CheckCircle2, XCircle, AlertTriangle, UserRound, Briefcase,
Plus, Pencil, Trash2, MapPin, Users, Filter, CalendarDays,
+ Mail,
} from 'lucide-vue-next'
definePageMeta({
@@ -321,6 +322,13 @@ const statusCounts = computed(() => {
Manage all scheduled interviews across your jobs
+
+
+ Email Templates
+
diff --git a/app/pages/dashboard/interviews/templates/[id].vue b/app/pages/dashboard/interviews/templates/[id].vue
new file mode 100644
index 00000000..77a234ee
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/[id].vue
@@ -0,0 +1,377 @@
+
+
+
+
+
+
+
+ All Templates
+
+
+
+
+
Template not found
+
This template may have been deleted or doesn't exist.
+
+ Back to Templates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ form.name }}
+
+
+
+ Built-in
+
+
+
+ {{ systemTemplate.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Danger Zone
+
Permanently delete this template. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
{{ previewSubject }}
+
+
+
Body
+
{{ previewBody }}
+
+
+
+
+ Preview uses sample data. Actual values are populated when sending.
+
+
+
+
+
+
+
+
+ Available Variables
+
+
+ Use these placeholders in your subject and body. They'll be replaced with real data when the email is sent.
+
+
+
+ {{ v.key }}
+ {{ v.desc }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/templates/index.vue b/app/pages/dashboard/interviews/templates/index.vue
new file mode 100644
index 00000000..ae922204
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/index.vue
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+ Back to Interviews
+
+
+
+
+
+
+
+
+
+
+ Email Templates
+
+
+
+ Manage reusable email templates for interview invitations. Use built-in templates or create your own with dynamic variables.
+
+
+
+
+ New Template
+
+
+
+
+
+
+
+
+ Built-in Templates
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.description }}
+
+
+ {{ t.subject }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Templates
+
+
+ {{ templates.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No custom templates yet
+
+
+ Create your own email templates to match your organization's voice and branding.
+
+
+
+ Create Your First Template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.subject }}
+
+
+ Updated {{ new Date(t.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Template
+
+ Are you sure you want to delete {{ templateToDelete?.name }}? This cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/templates/new.vue b/app/pages/dashboard/interviews/templates/new.vue
new file mode 100644
index 00000000..bdf66272
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/new.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+ All Templates
+
+
+
+
+
+
+
+
+
+
+ New Template
+
+
+ Create a reusable email template for interview invitations.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
+ {{ previewSubject || 'Enter a subject line…' }}
+
+
+
+
Body
+
+ {{ previewBody || 'Start writing to see a preview…' }}
+
+
+
+
+
+ Preview uses sample data. Actual values are populated when sending.
+
+
+
+
+
+
+
+
+ Available Variables
+
+
+ Use these placeholders in your subject and body. They'll be replaced with real data when the email is sent.
+
+
+
+ {{ v.key }}
+ {{ v.desc }}
+
+
+
+
+
+
+
diff --git a/app/utils/system-templates.ts b/app/utils/system-templates.ts
new file mode 100644
index 00000000..1a3ab553
--- /dev/null
+++ b/app/utils/system-templates.ts
@@ -0,0 +1,108 @@
+export interface SystemTemplate {
+ id: string
+ name: string
+ subject: string
+ body: string
+ description: string
+}
+
+export const SYSTEM_TEMPLATES: SystemTemplate[] = [
+ {
+ id: 'system-standard',
+ name: 'Standard Interview Invitation',
+ description: 'A professional and formal invitation suitable for most interview types.',
+ subject: 'Interview Invitation: {{jobTitle}} at {{organizationName}}',
+ body: `Dear {{candidateName}},
+
+We are pleased to invite you to an interview for the {{jobTitle}} position at {{organizationName}}.
+
+Interview Details:
+- Date: {{interviewDate}}
+- Time: {{interviewTime}}
+- Duration: {{interviewDuration}} minutes
+- Type: {{interviewType}}
+- Location: {{interviewLocation}}
+
+Interviewers: {{interviewers}}
+
+Please confirm your availability by replying to this email. If you need to reschedule, let us know as soon as possible.
+
+We look forward to speaking with you!
+
+Best regards,
+{{organizationName}}`,
+ },
+ {
+ id: 'system-friendly',
+ name: 'Friendly & Casual',
+ description: 'A warm, conversational tone that puts candidates at ease.',
+ subject: "Let's chat! Interview for {{jobTitle}}",
+ body: `Hi {{candidateFirstName}},
+
+Great news — we'd love to meet you for the {{jobTitle}} role at {{organizationName}}!
+
+Here are the details:
+- When: {{interviewDate}} at {{interviewTime}} ({{interviewDuration}} min)
+- How: {{interviewType}}
+- Where: {{interviewLocation}}
+
+You'll be speaking with: {{interviewers}}
+
+If this time doesn't work for you, just let us know and we'll find something that does.
+
+Looking forward to it!
+
+The {{organizationName}} Team`,
+ },
+ {
+ id: 'system-technical',
+ name: 'Technical Interview',
+ description: 'Tailored for technical interviews with preparation tips for candidates.',
+ subject: 'Technical Interview: {{jobTitle}} — {{organizationName}}',
+ body: `Dear {{candidateName}},
+
+Thank you for your interest in the {{jobTitle}} position at {{organizationName}}. We'd like to invite you to a technical interview.
+
+Interview Details:
+- Title: {{interviewTitle}}
+- Date: {{interviewDate}}
+- Time: {{interviewTime}}
+- Duration: {{interviewDuration}} minutes
+- Format: {{interviewType}}
+- Location: {{interviewLocation}}
+
+Your interviewer(s): {{interviewers}}
+
+To help you prepare:
+- Be ready to discuss your technical experience and problem-solving approach
+- You may be asked to write or review code during the session
+- Feel free to ask questions about our tech stack and development practices
+
+Please confirm your attendance by replying to this email.
+
+Best regards,
+{{organizationName}}`,
+ },
+]
+
+export const AVAILABLE_VARIABLES = [
+ { key: '{{candidateName}}', desc: 'Full name' },
+ { key: '{{candidateFirstName}}', desc: 'First name' },
+ { key: '{{candidateLastName}}', desc: 'Last name' },
+ { key: '{{candidateEmail}}', desc: 'Email address' },
+ { key: '{{jobTitle}}', desc: 'Job title' },
+ { key: '{{interviewTitle}}', desc: 'Interview title' },
+ { key: '{{interviewDate}}', desc: 'Interview date' },
+ { key: '{{interviewTime}}', desc: 'Interview time' },
+ { key: '{{interviewDuration}}', desc: 'Duration (min)' },
+ { key: '{{interviewType}}', desc: 'Interview type' },
+ { key: '{{interviewLocation}}', desc: 'Location/link' },
+ { key: '{{interviewers}}', desc: 'Interviewer names' },
+ { key: '{{organizationName}}', desc: 'Your org name' },
+] as const
+
+export function renderTemplatePreview(template: string, variables: Record): string {
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
+ return key in variables ? variables[key]! : match
+ })
+}
From 771917fd9b180b4babcfb6eb0c0192c4b5e44ebb Mon Sep 17 00:00:00 2001
From: Joachim
Date: Tue, 10 Mar 2026 17:13:50 +0100
Subject: [PATCH 05/36] feat: integrate email template selection for interview
invitations
---
app/components/InterviewScheduleSidebar.vue | 419 ++++++++++++--------
1 file changed, 251 insertions(+), 168 deletions(-)
diff --git a/app/components/InterviewScheduleSidebar.vue b/app/components/InterviewScheduleSidebar.vue
index 8bc27768..610ff6c0 100644
--- a/app/components/InterviewScheduleSidebar.vue
+++ b/app/components/InterviewScheduleSidebar.vue
@@ -2,8 +2,9 @@
import {
X, Calendar, Clock, MapPin, Users, Video, Phone,
Building2, Code2, FileText, UsersRound, ChevronLeft, ChevronRight,
- Plus, Minus, AlertCircle,
+ Plus, AlertCircle, Mail, ChevronDown,
} from 'lucide-vue-next'
+import { SYSTEM_TEMPLATES } from '~/utils/system-templates'
const props = defineProps<{
applicationId: string
@@ -25,13 +26,27 @@ const form = reactive({
duration: 60,
location: '',
notes: '',
- interviewers: [''] as string[],
+ interviewers: [] as string[],
})
const errors = ref>({})
const isSubmitting = ref(false)
const sendInvitationAfter = ref(false)
+// ─── Email templates ──────────────────────────────────────────────
+const { templates: customTemplates } = useEmailTemplates()
+const selectedTemplateId = ref('system-standard')
+const showTemplateDropdown = ref(false)
+
+const allTemplates = computed(() => [
+ ...SYSTEM_TEMPLATES.map(t => ({ id: t.id, name: t.name, description: t.description, isSystem: true as const })),
+ ...(customTemplates.value ?? []).map(t => ({ id: t.id, name: t.name, description: '', isSystem: false as const })),
+])
+
+const selectedTemplateName = computed(() => {
+ return allTemplates.value.find(t => t.id === selectedTemplateId.value)?.name ?? 'Select template'
+})
+
// Set a sensible default title
// Helper to extract YYYY-MM-DD from a Date object
function toDateString(d: Date): string {
@@ -219,7 +234,7 @@ async function handleSubmit() {
try {
await $fetch(`/api/interviews/${created.id}/send-invitation`, {
method: 'POST',
- body: { templateId: 'system-standard' },
+ body: { templateId: selectedTemplateId.value },
})
} catch {
// Interview was created successfully — don't block on email failure.
@@ -249,7 +264,7 @@ async function handleSubmit() {
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
-
+
@@ -261,62 +276,67 @@ async function handleSubmit() {
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
-
+
-
-
+
+
-
- Schedule Interview
-
-
+
+
+
+
+
+ Schedule Interview
+
+
+
{{ candidateName }} · {{ jobTitle }}
+
+
-
+
-
+
-