From b8c71137fa1af9e482b28be8407fb633fec87443 Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 09:39:56 +0200 Subject: [PATCH 01/12] Add recruiter notification engine - Implemented a durable outbox pattern for recruiter email notifications (new applicant, candidate reply, AI scoring). - Added `notificationOutbox` and `notificationPreference` schema tables. - Added scheduled workers for instant dispatch and daily digests. - Added instance-wide `NOTIFICATIONS_ENABLED` server kill-switch. - Added `notifications` feature flag for UI visibility. - Included email suppression tracking to maintain sender reputation. --- .env.example | 15 +- app/components/SettingsMobileNav.vue | 15 +- app/components/SettingsSidebar.vue | 16 +- app/composables/useNotificationPreferences.ts | 40 + .../dashboard/settings/notifications.vue | 174 + ee/server/api/webhooks/resend.post.ts | 101 +- nuxt.config.ts | 4 + .../api/admin/notification-dispatch.post.ts | 43 + server/api/applications/[id]/analyze.post.ts | 21 +- .../api/notification-preferences/index.get.ts | 35 + .../notification-preferences/index.patch.ts | 54 + server/api/public/jobs/[slug]/apply.post.ts | 34 +- .../migrations/0045_clammy_sleeper.sql | 55 + .../database/migrations/0046_far_hairball.sql | 6 + .../migrations/meta/0045_snapshot.json | 7431 ++++++++++++++++ .../migrations/meta/0046_snapshot.json | 7438 +++++++++++++++++ server/database/migrations/meta/_journal.json | 14 + server/database/schema/app.ts | 98 + server/tasks/notification-digest.ts | 13 + server/tasks/notification-dispatch.ts | 13 + server/utils/ai/autoScore.ts | 21 +- server/utils/email.ts | 253 +- server/utils/env.ts | 10 + server/utils/notifications/delivery-status.ts | 82 + server/utils/notifications/dispatch.ts | 344 + server/utils/notifications/enqueue.ts | 69 + server/utils/notifications/recipients.ts | 65 + server/utils/notifications/templates.ts | 333 + server/utils/notifications/urls.ts | 27 + shared/feature-flags.ts | 10 + shared/notifications.ts | 53 + .../unit/notification-delivery-status.test.ts | 96 + tests/unit/notification-dispatch.test.ts | 195 + tests/unit/notification-enqueue.test.ts | 99 + tests/unit/notification-recipients.test.ts | 57 + tests/unit/notification-templates.test.ts | 75 + 36 files changed, 17188 insertions(+), 221 deletions(-) create mode 100644 app/composables/useNotificationPreferences.ts create mode 100644 app/pages/dashboard/settings/notifications.vue create mode 100644 server/api/admin/notification-dispatch.post.ts create mode 100644 server/api/notification-preferences/index.get.ts create mode 100644 server/api/notification-preferences/index.patch.ts create mode 100644 server/database/migrations/0045_clammy_sleeper.sql create mode 100644 server/database/migrations/0046_far_hairball.sql create mode 100644 server/database/migrations/meta/0045_snapshot.json create mode 100644 server/database/migrations/meta/0046_snapshot.json create mode 100644 server/tasks/notification-digest.ts create mode 100644 server/tasks/notification-dispatch.ts create mode 100644 server/utils/notifications/delivery-status.ts create mode 100644 server/utils/notifications/dispatch.ts create mode 100644 server/utils/notifications/enqueue.ts create mode 100644 server/utils/notifications/recipients.ts create mode 100644 server/utils/notifications/templates.ts create mode 100644 server/utils/notifications/urls.ts create mode 100644 shared/notifications.ts create mode 100644 tests/unit/notification-delivery-status.test.ts create mode 100644 tests/unit/notification-dispatch.test.ts create mode 100644 tests/unit/notification-enqueue.test.ts create mode 100644 tests/unit/notification-recipients.test.ts create mode 100644 tests/unit/notification-templates.test.ts diff --git a/.env.example b/.env.example index 6b587d22..e7e4dc2f 100644 --- a/.env.example +++ b/.env.example @@ -49,9 +49,22 @@ S3_FORCE_PATH_STYLE=true # leave unset/false to guarantee NO automatic deletion. Set to true only after # you have reviewed your retention policy and want the sweep to run. GDPR_CLEANUP_ENABLED=false -# Optional: authenticates external schedulers calling /api/admin/retention-cleanup. +# Optional: authenticates external schedulers calling /api/admin/retention-cleanup +# and /api/admin/notification-dispatch. CRON_SECRET= +# ─── Recruiter notifications ──────────────────────────────────────────────── +# Email notifications to recruiters (new applicant, candidate reply, AI scoring +# finished) via a durable outbox drained by a scheduled worker. Fail-safe +# default = true (activation driver, safe to send). Set to false to pause all +# notification sending + digests instance-wide. +NOTIFICATIONS_ENABLED=true +# Delivery status + hard-bounce/complaint suppression reuse the existing +# RESEND_WEBHOOK_SECRET below. External schedulers can trigger the worker at +# /api/admin/notification-dispatch using CRON_SECRET (as above). +# The 'notifications' feature flag (FEATURE_FLAG_NOTIFICATIONS=false) is a +# separate client-visible kill-switch; both default on. + # ─── SEO ───────────────────────────────────────────────────────────────────── # Used by @nuxtjs/seo for sitemaps, canonical URLs, and OG tags NUXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/app/components/SettingsMobileNav.vue b/app/components/SettingsMobileNav.vue index f0c095f4..6c3f2b46 100644 --- a/app/components/SettingsMobileNav.vue +++ b/app/components/SettingsMobileNav.vue @@ -1,10 +1,11 @@ + + diff --git a/ee/server/api/webhooks/resend.post.ts b/ee/server/api/webhooks/resend.post.ts index 0c0f1150..4255922b 100644 --- a/ee/server/api/webhooks/resend.post.ts +++ b/ee/server/api/webhooks/resend.post.ts @@ -6,7 +6,9 @@ import { candidateMessageAttachment, candidateMessageWebhookEvent, } from '~~/server/database/schema' +import { enqueueNotification } from '~~/server/utils/notifications/enqueue' import { getResendClient, getResendReceivingClient } from '~~/server/utils/email' +import { processNotificationStatusEvent } from '~~/server/utils/notifications/delivery-status' import { deleteFromS3, uploadToS3 } from '~~/server/utils/s3' import { CANDIDATE_MESSAGE_MAX_ATTACHMENT_BYTES, @@ -38,7 +40,10 @@ const STATUS_BY_EVENT = { } as const export default defineEventHandler(async (event) => { - const { replyDomain, webhookSecret } = requireCandidateMessagingConfig() + const webhookSecret = env.RESEND_WEBHOOK_SECRET + if (!webhookSecret) { + throw createError({ statusCode: 503, statusMessage: 'Resend webhook is not configured' }) + } const payload = await readRawBody(event, 'utf8') const webhookId = getHeader(event, 'svix-id') const timestamp = getHeader(event, 'svix-timestamp') @@ -79,6 +84,7 @@ export default defineEventHandler(async (event) => { try { if (verified.type === 'email.received') { + const { replyDomain } = requireCandidateMessagingConfig() await processInboundMessage(verified, replyDomain) } else if (verified.type in STATUS_BY_EVENT) { await processStatusEvent(verified as StatusWebhookEvent) @@ -104,6 +110,13 @@ export default defineEventHandler(async (event) => { type StatusWebhookEvent = Extract async function processStatusEvent(event: StatusWebhookEvent): Promise { + // Recruiter notifications share this webhook — route their delivery events to + // the outbox instead of the candidate-message table. + if (event.data.tags?.category === 'notification') { + await processNotificationStatusEvent(event) + return + } + const status = STATUS_BY_EVENT[event.type] const eventAt = new Date(event.created_at) const taggedMessageId = event.data.tags?.message @@ -154,7 +167,10 @@ async function processInboundMessage( where: eq(candidateConversation.replyToken, token), with: { application: { - with: { candidate: { columns: { email: true } } }, + with: { + candidate: { columns: { email: true, firstName: true, lastName: true } }, + job: { columns: { title: true } }, + }, }, }, }) @@ -188,39 +204,58 @@ async function processInboundMessage( return entry?.[1] } const createdAt = new Date(data.created_at) - const [inserted] = await db.insert(candidateMessage).values({ - organizationId: conversation.organizationId, - conversationId: conversation.id, - direction: 'inbound', - status: 'delivered', - fromEmail, - toEmail: normalizeEmailAddress(data.to[0] ?? event.data.to[0] ?? ''), - subject: data.subject || event.data.subject || '(No subject)', - bodyText: inboundTextContent(data.text, data.html), - providerMessageId: data.id, - internetMessageId: data.message_id, - inReplyTo: header('in-reply-to'), - references: parseReferences(header('references')), - providerStatusAt: createdAt, - sentAt: createdAt, - deliveredAt: createdAt, - createdAt, - updatedAt: createdAt, - }).onConflictDoNothing().returning({ id: candidateMessage.id }) + const bodyText = inboundTextContent(data.text, data.html) + const { storedMessage } = await db.transaction(async (tx) => { + const [inserted] = await tx.insert(candidateMessage).values({ + organizationId: conversation.organizationId, + conversationId: conversation.id, + direction: 'inbound', + status: 'delivered', + fromEmail, + toEmail: normalizeEmailAddress(data.to[0] ?? event.data.to[0] ?? ''), + subject: data.subject || event.data.subject || '(No subject)', + bodyText, + providerMessageId: data.id, + internetMessageId: data.message_id, + inReplyTo: header('in-reply-to'), + references: parseReferences(header('references')), + providerStatusAt: createdAt, + sentAt: createdAt, + deliveredAt: createdAt, + createdAt, + updatedAt: createdAt, + }).onConflictDoNothing().returning({ id: candidateMessage.id }) - const storedMessage = inserted ?? await db.query.candidateMessage.findFirst({ - where: eq(candidateMessage.providerMessageId, data.id), - columns: { id: true }, - }) - if (!storedMessage) throw new Error('Inbound message could not be persisted') + const storedMessage = inserted ?? await tx.query.candidateMessage.findFirst({ + where: eq(candidateMessage.providerMessageId, data.id), + columns: { id: true }, + }) + if (!storedMessage) throw new Error('Inbound message could not be persisted') - if (inserted) { - await db.update(candidateConversation).set({ - unreadCount: sql`${candidateConversation.unreadCount} + 1`, - lastMessageAt: createdAt, - updatedAt: new Date(), - }).where(eq(candidateConversation.id, conversation.id)) - } + if (inserted) { + await tx.update(candidateConversation).set({ + unreadCount: sql`${candidateConversation.unreadCount} + 1`, + lastMessageAt: createdAt, + updatedAt: new Date(), + }).where(eq(candidateConversation.id, conversation.id)) + + const candidateName = `${conversation.application.candidate.firstName} ${conversation.application.candidate.lastName}`.trim() + await enqueueNotification({ + organizationId: conversation.organizationId, + type: 'candidate_replied', + dedupeKey: `candidate_replied:${storedMessage.id}`, + payload: { + applicationId: conversation.applicationId, + conversationId: conversation.id, + candidateName, + jobTitle: conversation.application.job.title, + preview: bodyText.slice(0, 200), + }, + tx, + }) + } + return { storedMessage } + }) if (data.attachments.length > 0) { await captureInboundAttachments({ diff --git a/nuxt.config.ts b/nuxt.config.ts index 4aa49591..b5022039 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -298,9 +298,13 @@ export default defineNuxtConfig({ tasks: true, }, scheduledTasks: { + // Every minute: drain the recruiter notification outbox (instant cadence). + "* * * * *": ["notification-dispatch"], // Daily at 03:00 UTC. External cron remains supported for platforms that // suspend long-running processes or do not execute Nitro task timers. "0 3 * * *": ["retention-cleanup"], + // Daily at 08:00 UTC: roll up digest-cadence notifications per recipient. + "0 8 * * *": ["notification-digest"], }, routeRules: { "/**": { diff --git a/server/api/admin/notification-dispatch.post.ts b/server/api/admin/notification-dispatch.post.ts new file mode 100644 index 00000000..bdb7f09d --- /dev/null +++ b/server/api/admin/notification-dispatch.post.ts @@ -0,0 +1,43 @@ +/** + * POST /api/admin/notification-dispatch + * + * External cron and interactive admin entrypoint for the notification outbox + * worker. Mirrors /api/admin/retention-cleanup: a valid `x-cron-secret` runs it + * as a scheduled trigger, otherwise it requires an authenticated admin. Provides + * a timer-suspend-safe way to drain the outbox if Railway pauses the in-process + * Nitro cron. + * + * Pass `{ "digest": true }` to run the daily digest job instead of instant dispatch. + */ +import { timingSafeEqual } from 'node:crypto' +import { z } from 'zod' +import { runNotificationDispatch, runNotificationDigest } from '../../utils/notifications/dispatch' + +const bodySchema = z.object({ + digest: z.boolean().optional().default(false), + batchSize: z.number().int().min(1).max(500).optional(), +}) + +export default defineEventHandler(async (event) => { + const cronSecret = getHeader(event, 'x-cron-secret') + let source: 'cron_endpoint' | 'interactive' = 'interactive' + + if (cronSecret && env.CRON_SECRET) { + const supplied = Buffer.from(cronSecret) + const expected = Buffer.from(env.CRON_SECRET) + const valid = supplied.length === expected.length && timingSafeEqual(supplied, expected) + if (!valid) { + throw createError({ statusCode: 403, statusMessage: 'Invalid cron secret' }) + } + source = 'cron_endpoint' + } + else { + // Reuse an existing high-trust permission — draining the queue is an admin op. + await requirePermission(event, { organization: ['update'] }) + } + + const body = await readValidatedBody(event, bodySchema.parse) + return body.digest + ? runNotificationDigest({ source, batchSize: body.batchSize }) + : runNotificationDispatch({ source, batchSize: body.batchSize }) +}) diff --git a/server/api/applications/[id]/analyze.post.ts b/server/api/applications/[id]/analyze.post.ts index 25850486..d458bc02 100644 --- a/server/api/applications/[id]/analyze.post.ts +++ b/server/api/applications/[id]/analyze.post.ts @@ -12,6 +12,7 @@ import { captureAiGeneration } from '../../../utils/ai/observability' import { extractResumeText } from '../../../utils/resume-parser' import { fetchScreeningAnswers, DEFAULT_ANALYSIS_CONTEXT } from '../../../utils/ai/analysisContext' import { parseAndPersistDocument } from '../../../utils/document-parser' +import { enqueueNotification } from '../../../utils/notifications/enqueue' import { createRateLimiter } from '../../../utils/rateLimit' import { z } from 'zod' @@ -227,7 +228,7 @@ export default defineEventHandler(async (event) => { .where(eq(application.id, applicationId)) // Record analysis run - return tx.insert(analysisRun).values({ + const inserted = await tx.insert(analysisRun).values({ organizationId: orgId, applicationId, status: 'completed', @@ -241,6 +242,24 @@ export default defineEventHandler(async (event) => { costUsdMicros, scoredById: session.user.id, }).returning() + + // Enqueue in the same tx so the notification is never lost if the run commits. + const runRow = inserted[0] + if (runRow) { + await enqueueNotification({ + organizationId: orgId, + type: 'analysis_completed', + dedupeKey: `analysis_completed:${runRow.id}`, + payload: { + applicationId, + candidateName: `${app.candidate.firstName} ${app.candidate.lastName}`.trim(), + jobTitle: app.job.title, + compositeScore, + }, + tx, + }) + } + return inserted }) captureAiGeneration({ diff --git a/server/api/notification-preferences/index.get.ts b/server/api/notification-preferences/index.get.ts new file mode 100644 index 00000000..399e3201 --- /dev/null +++ b/server/api/notification-preferences/index.get.ts @@ -0,0 +1,35 @@ +import { and, eq } from 'drizzle-orm' +import { notificationPreference } from '../../database/schema' +import { DEFAULT_CHANNEL_MODE, NOTIFICATION_TYPES } from '~~/shared/notifications' + +/** + * GET /api/notification-preferences + * Returns the current user's per-event notification preferences for the active + * org, with unset events filled in from the sensible defaults. + */ +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const orgId = session.session.activeOrganizationId + const userId = session.user.id + const enabled = await resolveFeatureFlagForEvent(event, 'notifications', { + userId, + organizationId: orgId, + }) + if (!enabled) throw createError({ statusCode: 404, statusMessage: 'Not found' }) + + const rows = await db.select({ + type: notificationPreference.type, + channelMode: notificationPreference.channelMode, + }) + .from(notificationPreference) + .where(and( + eq(notificationPreference.userId, userId), + eq(notificationPreference.organizationId, orgId), + )) + const byType = new Map(rows.map(r => [r.type, r.channelMode])) + + return NOTIFICATION_TYPES.map(type => ({ + type, + channelMode: byType.get(type) ?? DEFAULT_CHANNEL_MODE[type], + })) +}) diff --git a/server/api/notification-preferences/index.patch.ts b/server/api/notification-preferences/index.patch.ts new file mode 100644 index 00000000..46211cb3 --- /dev/null +++ b/server/api/notification-preferences/index.patch.ts @@ -0,0 +1,54 @@ +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { notificationPreference } from '../../database/schema' +import { DEFAULT_CHANNEL_MODE, NOTIFICATION_TYPES } from '~~/shared/notifications' + +const bodySchema = z.object({ + preferences: z.array(z.object({ + type: z.enum(NOTIFICATION_TYPES), + channelMode: z.enum(['instant', 'digest', 'off']), + })).min(1), +}) + +/** + * PATCH /api/notification-preferences + * Upsert one or more of the current user's per-event preferences for the active + * org, then return the full (defaults-filled) set — same shape as the GET route. + */ +export default defineEventHandler(async (event) => { + const session = await requireAuth(event) + const orgId = session.session.activeOrganizationId + const userId = session.user.id + const enabled = await resolveFeatureFlagForEvent(event, 'notifications', { + userId, + organizationId: orgId, + }) + if (!enabled) throw createError({ statusCode: 404, statusMessage: 'Not found' }) + + const { preferences } = await readValidatedBody(event, bodySchema.parse) + + for (const pref of preferences) { + await db.insert(notificationPreference) + .values({ userId, organizationId: orgId, type: pref.type, channelMode: pref.channelMode }) + .onConflictDoUpdate({ + target: [notificationPreference.userId, notificationPreference.organizationId, notificationPreference.type], + set: { channelMode: pref.channelMode, updatedAt: new Date() }, + }) + } + + const rows = await db.select({ + type: notificationPreference.type, + channelMode: notificationPreference.channelMode, + }) + .from(notificationPreference) + .where(and( + eq(notificationPreference.userId, userId), + eq(notificationPreference.organizationId, orgId), + )) + const byType = new Map(rows.map(r => [r.type, r.channelMode])) + + return NOTIFICATION_TYPES.map(type => ({ + type, + channelMode: byType.get(type) ?? DEFAULT_CHANNEL_MODE[type], + })) +}) diff --git a/server/api/public/jobs/[slug]/apply.post.ts b/server/api/public/jobs/[slug]/apply.post.ts index 1c608489..b17ead66 100644 --- a/server/api/public/jobs/[slug]/apply.post.ts +++ b/server/api/public/jobs/[slug]/apply.post.ts @@ -13,6 +13,7 @@ import { sanitizeFilename, } from '../../../../utils/schemas/document' import { restoreCandidateForPublicApplication } from '../../../../utils/candidate-retention' +import { enqueueNotification } from '../../../../utils/notifications/enqueue' /** Rate limit: max 5 applications per IP per 15 minutes */ const applyRateLimit = createRateLimiter({ @@ -182,6 +183,7 @@ export default defineEventHandler(async (event) => { columns: { id: true, organizationId: true, + title: true, phoneRequirement: true, requireResume: true, requireCoverLetter: true, @@ -417,13 +419,31 @@ export default defineEventHandler(async (event) => { // 8. Create application // ───────────────────────────────────────────── - const [newApplication] = await db.insert(application).values({ - organizationId: orgId, - candidateId, - jobId, - status: 'new', - coverLetterText: coverLetterText || null, - }).returning({ id: application.id }) + const [newApplication] = await db.transaction(async (tx) => { + const inserted = await tx.insert(application).values({ + organizationId: orgId, + candidateId, + jobId, + status: 'new', + coverLetterText: coverLetterText || null, + }).returning({ id: application.id }) + + const row = inserted[0] + if (row) { + await enqueueNotification({ + organizationId: orgId, + type: 'application_created', + dedupeKey: `application_created:${row.id}`, + payload: { + applicationId: row.id, + candidateName: `${firstName} ${lastName}`.trim(), + jobTitle: existingJob.title, + }, + tx, + }) + } + return inserted + }) // ───────────────────────────────────────────── // 8b. Record source attribution diff --git a/server/database/migrations/0045_clammy_sleeper.sql b/server/database/migrations/0045_clammy_sleeper.sql new file mode 100644 index 00000000..75d26af2 --- /dev/null +++ b/server/database/migrations/0045_clammy_sleeper.sql @@ -0,0 +1,55 @@ +CREATE TYPE "public"."email_suppression_reason" AS ENUM('bounce', 'complaint');--> statement-breakpoint +CREATE TYPE "public"."notification_cadence" AS ENUM('instant', 'digest');--> statement-breakpoint +CREATE TYPE "public"."notification_channel_mode" AS ENUM('instant', 'digest', 'off');--> statement-breakpoint +CREATE TYPE "public"."notification_outbox_status" AS ENUM('pending', 'sent', 'skipped', 'dead');--> statement-breakpoint +CREATE TYPE "public"."notification_type" AS ENUM('candidate_replied', 'application_created', 'analysis_completed');--> statement-breakpoint +CREATE TABLE "email_suppression" ( + "id" text PRIMARY KEY NOT NULL, + "email" text NOT NULL, + "reason" "email_suppression_reason" NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_outbox" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "recipient_user_id" text NOT NULL, + "recipient_email" text NOT NULL, + "type" "notification_type" NOT NULL, + "cadence" "notification_cadence" NOT NULL, + "dedupe_key" text NOT NULL, + "payload" jsonb NOT NULL, + "status" "notification_outbox_status" DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "next_attempt_at" timestamp DEFAULT now() NOT NULL, + "digest_bucket" text, + "provider_message_id" text, + "sent_at" timestamp, + "failed_at" timestamp, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_preference" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "organization_id" text NOT NULL, + "type" "notification_type" NOT NULL, + "channel_mode" "notification_channel_mode" NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "email_suppression_email_idx" ON "email_suppression" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX "notification_outbox_dedupe_key_idx" ON "notification_outbox" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "notification_outbox_pull_idx" ON "notification_outbox" USING btree ("status","next_attempt_at");--> statement-breakpoint +CREATE UNIQUE INDEX "notification_outbox_provider_id_idx" ON "notification_outbox" USING btree ("provider_message_id") WHERE "notification_outbox"."provider_message_id" is not null;--> statement-breakpoint +CREATE INDEX "notification_outbox_organization_id_idx" ON "notification_outbox" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "notification_outbox_digest_idx" ON "notification_outbox" USING btree ("recipient_user_id","digest_bucket");--> statement-breakpoint +CREATE UNIQUE INDEX "notification_preference_user_org_type_idx" ON "notification_preference" USING btree ("user_id","organization_id","type");--> statement-breakpoint +CREATE INDEX "notification_preference_organization_id_idx" ON "notification_preference" USING btree ("organization_id"); \ No newline at end of file diff --git a/server/database/migrations/0046_far_hairball.sql b/server/database/migrations/0046_far_hairball.sql new file mode 100644 index 00000000..7f57fd52 --- /dev/null +++ b/server/database/migrations/0046_far_hairball.sql @@ -0,0 +1,6 @@ +DROP INDEX "email_suppression_email_idx";--> statement-breakpoint +DROP INDEX "notification_outbox_pull_idx";--> statement-breakpoint +DROP INDEX "notification_outbox_digest_idx";--> statement-breakpoint +CREATE UNIQUE INDEX "email_suppression_email_idx" ON "email_suppression" USING btree (lower("email"));--> statement-breakpoint +CREATE INDEX "notification_outbox_pull_idx" ON "notification_outbox" USING btree ("cadence","next_attempt_at") WHERE "notification_outbox"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "notification_outbox_digest_idx" ON "notification_outbox" USING btree ("organization_id","recipient_user_id","digest_bucket"); \ No newline at end of file diff --git a/server/database/migrations/meta/0045_snapshot.json b/server/database/migrations/meta/0045_snapshot.json new file mode 100644 index 00000000..f8a6ff9c --- /dev/null +++ b/server/database/migrations/meta/0045_snapshot.json @@ -0,0 +1,7431 @@ +{ + "id": "f48bcc31-f212-4a00-a07c-5b3e07fa9309", + "prevId": "07091fde-3b45-4473-9ba0-3aa24f504d14", + "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 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "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.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rate_limit_key_idx": { + "name": "rate_limit_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_id_idx": { + "name": "subscription_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_stripe_customer_id_idx": { + "name": "subscription_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "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.ai_config": { + "name": "ai_config", + "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, + "default": "'Default'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gpt-4o-mini'" + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "is_default_chatbot": { + "name": "is_default_chatbot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default_analysis": { + "name": "is_default_analysis", + "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": { + "ai_config_organization_id_idx": { + "name": "ai_config_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_config_default_chatbot_idx": { + "name": "ai_config_default_chatbot_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_config\".\"is_default_chatbot\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_config_default_analysis_idx": { + "name": "ai_config_default_analysis_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_config\".\"is_default_analysis\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_config_organization_id_organization_id_fk": { + "name": "ai_config_organization_id_organization_id_fk", + "tableFrom": "ai_config", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analysis_run": { + "name": "analysis_run", + "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 + }, + "status": { + "name": "status", + "type": "analysis_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "criteria_snapshot": { + "name": "criteria_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "composite_score": { + "name": "composite_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd_micros": { + "name": "cost_usd_micros", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_mode": { + "name": "billing_mode", + "type": "analysis_billing_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'byok'" + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scored_by_id": { + "name": "scored_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analysis_run_organization_id_idx": { + "name": "analysis_run_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_application_id_idx": { + "name": "analysis_run_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_created_at_idx": { + "name": "analysis_run_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analysis_run_organization_id_organization_id_fk": { + "name": "analysis_run_organization_id_organization_id_fk", + "tableFrom": "analysis_run", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_application_id_application_id_fk": { + "name": "analysis_run_application_id_application_id_fk", + "tableFrom": "analysis_run", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_scored_by_id_user_id_fk": { + "name": "analysis_run_scored_by_id_user_id_fk", + "tableFrom": "analysis_run", + "tableTo": "user", + "columnsFrom": [ + "scored_by_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.application_source": { + "name": "application_source", + "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 + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "tracking_link_id": { + "name": "tracking_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_domain": { + "name": "referrer_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_source_organization_id_idx": { + "name": "application_source_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_id_idx": { + "name": "application_source_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_channel_idx": { + "name": "application_source_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_tracking_link_id_idx": { + "name": "application_source_tracking_link_id_idx", + "columns": [ + { + "expression": "tracking_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_idx": { + "name": "application_source_application_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_source_organization_id_organization_id_fk": { + "name": "application_source_organization_id_organization_id_fk", + "tableFrom": "application_source", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_application_id_application_id_fk": { + "name": "application_source_application_id_application_id_fk", + "tableFrom": "application_source", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_tracking_link_id_tracking_link_id_fk": { + "name": "application_source_tracking_link_id_tracking_link_id_fk", + "tableFrom": "application_source", + "tableTo": "tracking_link", + "columnsFrom": [ + "tracking_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_integration": { + "name": "calendar_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "calendar_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_channel_id": { + "name": "webhook_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_resource_id": { + "name": "webhook_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_expiration": { + "name": "webhook_expiration", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_token": { + "name": "sync_token", + "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": { + "calendar_integration_user_provider_idx": { + "name": "calendar_integration_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_integration_webhook_channel_idx": { + "name": "calendar_integration_webhook_channel_idx", + "columns": [ + { + "expression": "webhook_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_integration_user_id_user_id_fk": { + "name": "calendar_integration_user_id_user_id_fk", + "tableFrom": "calendar_integration", + "tableTo": "user", + "columnsFrom": [ + "user_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 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_notes": { + "name": "quick_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_exempt_until": { + "name": "retention_exempt_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retention_exempt_reason": { + "name": "retention_exempt_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_reviewed_at": { + "name": "retention_reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scheduled_purge_at": { + "name": "scheduled_purge_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": { + "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_gender_idx": { + "name": "candidate_gender_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gender", + "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": {} + }, + "candidate_quarantine_idx": { + "name": "candidate_quarantine_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_purge_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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.candidate_conversation": { + "name": "candidate_conversation", + "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 + }, + "reply_token": { + "name": "reply_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_message_at": { + "name": "last_message_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": { + "candidate_conversation_organization_id_idx": { + "name": "candidate_conversation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_application_id_idx": { + "name": "candidate_conversation_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_reply_token_idx": { + "name": "candidate_conversation_reply_token_idx", + "columns": [ + { + "expression": "reply_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_last_message_at_idx": { + "name": "candidate_conversation_last_message_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_conversation_organization_id_organization_id_fk": { + "name": "candidate_conversation_organization_id_organization_id_fk", + "tableFrom": "candidate_conversation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_conversation_application_id_application_id_fk": { + "name": "candidate_conversation_application_id_application_id_fk", + "tableFrom": "candidate_conversation", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message": { + "name": "candidate_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "candidate_message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "candidate_message_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'message'" + }, + "interview_id": { + "name": "interview_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendar_attachment_status": { + "name": "calendar_attachment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "calendar_attachment_error": { + "name": "calendar_attachment_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendar_sequence": { + "name": "calendar_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internet_message_id": { + "name": "internet_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "references": { + "name": "references", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_by_id": { + "name": "sent_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_status_at": { + "name": "provider_status_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_message_organization_id_idx": { + "name": "candidate_message_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_conversation_id_idx": { + "name": "candidate_message_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_interview_id_idx": { + "name": "candidate_message_interview_id_idx", + "columns": [ + { + "expression": "interview_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_provider_id_idx": { + "name": "candidate_message_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_internet_id_idx": { + "name": "candidate_message_internet_id_idx", + "columns": [ + { + "expression": "internet_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_message_organization_id_organization_id_fk": { + "name": "candidate_message_organization_id_organization_id_fk", + "tableFrom": "candidate_message", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_conversation_id_candidate_conversation_id_fk": { + "name": "candidate_message_conversation_id_candidate_conversation_id_fk", + "tableFrom": "candidate_message", + "tableTo": "candidate_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_interview_id_interview_id_fk": { + "name": "candidate_message_interview_id_interview_id_fk", + "tableFrom": "candidate_message", + "tableTo": "interview", + "columnsFrom": [ + "interview_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "candidate_message_sent_by_id_user_id_fk": { + "name": "candidate_message_sent_by_id_user_id_fk", + "tableFrom": "candidate_message", + "tableTo": "user", + "columnsFrom": [ + "sent_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message_attachment": { + "name": "candidate_message_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "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": true + }, + "provider_attachment_id": { + "name": "provider_attachment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_message_attachment_organization_id_idx": { + "name": "candidate_message_attachment_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_attachment_message_id_idx": { + "name": "candidate_message_attachment_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_message_attachment_organization_id_organization_id_fk": { + "name": "candidate_message_attachment_organization_id_organization_id_fk", + "tableFrom": "candidate_message_attachment", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_attachment_message_id_candidate_message_id_fk": { + "name": "candidate_message_attachment_message_id_candidate_message_id_fk", + "tableFrom": "candidate_message_attachment", + "tableTo": "candidate_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "candidate_message_attachment_storage_key_unique": { + "name": "candidate_message_attachment_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + }, + "candidate_message_attachment_provider_attachment_id_unique": { + "name": "candidate_message_attachment_provider_attachment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_attachment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message_webhook_event": { + "name": "candidate_message_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_message_webhook_provider_id_idx": { + "name": "candidate_message_webhook_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_webhook_unprocessed_idx": { + "name": "candidate_message_webhook_unprocessed_idx", + "columns": [ + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.career_page": { + "name": "career_page", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_storage_key": { + "name": "logo_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_storage_key": { + "name": "banner_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_position": { + "name": "banner_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "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": { + "career_page_organization_id_idx": { + "name": "career_page_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "career_page_slug_idx": { + "name": "career_page_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "career_page_organization_id_organization_id_fk": { + "name": "career_page_organization_id_organization_id_fk", + "tableFrom": "career_page", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_agent": { + "name": "chatbot_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "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": { + "chatbot_agent_org_user_idx": { + "name": "chatbot_agent_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_agent_default_per_user_idx": { + "name": "chatbot_agent_default_per_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chatbot_agent\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_agent_organization_id_organization_id_fk": { + "name": "chatbot_agent_organization_id_organization_id_fk", + "tableFrom": "chatbot_agent", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_agent_user_id_user_id_fk": { + "name": "chatbot_agent_user_id_user_id_fk", + "tableFrom": "chatbot_agent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_conversation": { + "name": "chatbot_conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_config_id": { + "name": "ai_config_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "thinking": { + "name": "thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_message_preview": { + "name": "last_message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_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": { + "chatbot_conversation_org_user_idx": { + "name": "chatbot_conversation_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_conversation_folder_idx": { + "name": "chatbot_conversation_folder_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_conversation_last_message_at_idx": { + "name": "chatbot_conversation_last_message_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_conversation_organization_id_organization_id_fk": { + "name": "chatbot_conversation_organization_id_organization_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_conversation_user_id_user_id_fk": { + "name": "chatbot_conversation_user_id_user_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_conversation_folder_id_chatbot_folder_id_fk": { + "name": "chatbot_conversation_folder_id_chatbot_folder_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "chatbot_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chatbot_conversation_agent_id_chatbot_agent_id_fk": { + "name": "chatbot_conversation_agent_id_chatbot_agent_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "chatbot_agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chatbot_conversation_ai_config_id_ai_config_id_fk": { + "name": "chatbot_conversation_ai_config_id_ai_config_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "ai_config", + "columnsFrom": [ + "ai_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_folder": { + "name": "chatbot_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "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": { + "chatbot_folder_org_user_idx": { + "name": "chatbot_folder_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_folder_organization_id_organization_id_fk": { + "name": "chatbot_folder_organization_id_organization_id_fk", + "tableFrom": "chatbot_folder", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_folder_user_id_user_id_fk": { + "name": "chatbot_folder_user_id_user_id_fk", + "tableFrom": "chatbot_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_message": { + "name": "chatbot_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "chatbot_message_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatbot_message_conversation_idx": { + "name": "chatbot_message_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_message_conversation_id_chatbot_conversation_id_fk": { + "name": "chatbot_message_conversation_id_chatbot_conversation_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "chatbot_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_message_organization_id_organization_id_fk": { + "name": "chatbot_message_organization_id_organization_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_message_user_id_user_id_fk": { + "name": "chatbot_message_user_id_user_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "user", + "columnsFrom": [ + "user_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.criterion_score": { + "name": "criterion_score", + "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 + }, + "criterion_key": { + "name": "criterion_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "applicant_score": { + "name": "applicant_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strengths": { + "name": "strengths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gaps": { + "name": "gaps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "criterion_score_organization_id_idx": { + "name": "criterion_score_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_application_id_idx": { + "name": "criterion_score_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_app_criterion_idx": { + "name": "criterion_score_app_criterion_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "criterion_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "criterion_score_organization_id_organization_id_fk": { + "name": "criterion_score_organization_id_organization_id_fk", + "tableFrom": "criterion_score", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "criterion_score_application_id_application_id_fk": { + "name": "criterion_score_application_id_application_id_fk", + "tableFrom": "criterion_score", + "tableTo": "application", + "columnsFrom": [ + "application_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_suppression": { + "name": "email_suppression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "email_suppression_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppression_email_idx": { + "name": "email_suppression_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "personal_note": { + "name": "personal_note", + "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 + }, + "candidate_response": { + "name": "candidate_response", + "type": "candidate_response", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "candidate_responded_at": { + "name": "candidate_responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_id": { + "name": "google_calendar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_link": { + "name": "google_calendar_event_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "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 + }, + "salary_negotiable": { + "name": "salary_negotiable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_status": { + "name": "remote_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_through": { + "name": "valid_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "experience_level": { + "name": "experience_level", + "type": "experience_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "phone_requirement": { + "name": "phone_requirement", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'optional'" + }, + "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 + }, + "auto_score_on_apply": { + "name": "auto_score_on_apply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "analysis_context": { + "name": "analysis_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"coverLetter\":true,\"screeningAnswers\":true,\"recruiterNotes\":false}'::jsonb" + }, + "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.notification_outbox": { + "name": "notification_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "notification_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "digest_bucket": { + "name": "digest_bucket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": { + "notification_outbox_dedupe_key_idx": { + "name": "notification_outbox_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_pull_idx": { + "name": "notification_outbox_pull_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_provider_id_idx": { + "name": "notification_outbox_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification_outbox\".\"provider_message_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_organization_id_idx": { + "name": "notification_outbox_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_digest_idx": { + "name": "notification_outbox_digest_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "digest_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_organization_id_organization_id_fk": { + "name": "notification_outbox_organization_id_organization_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_recipient_user_id_user_id_fk": { + "name": "notification_outbox_recipient_user_id_user_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "user", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preference": { + "name": "notification_preference", + "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 + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "channel_mode": { + "name": "channel_mode", + "type": "notification_channel_mode", + "typeSchema": "public", + "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": { + "notification_preference_user_org_type_idx": { + "name": "notification_preference_user_org_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_preference_organization_id_idx": { + "name": "notification_preference_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preference_user_id_user_id_fk": { + "name": "notification_preference_user_id_user_id_fk", + "tableFrom": "notification_preference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_preference_organization_id_organization_id_fk": { + "name": "notification_preference_organization_id_organization_id_fk", + "tableFrom": "notification_preference", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_survey_response": { + "name": "onboarding_survey_response", + "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 + }, + "signup_plan": { + "name": "signup_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_billing": { + "name": "signup_billing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_size": { + "name": "company_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_role": { + "name": "user_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discovery_source": { + "name": "discovery_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_hiring_process": { + "name": "current_hiring_process", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expected_roles_12m": { + "name": "expected_roles_12m", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answered_count": { + "name": "answered_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "onboarding_survey_response_user_id_idx": { + "name": "onboarding_survey_response_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboarding_survey_response_organization_id_idx": { + "name": "onboarding_survey_response_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_survey_response_user_id_user_id_fk": { + "name": "onboarding_survey_response_user_id_user_id_fk", + "tableFrom": "onboarding_survey_response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_survey_response_organization_id_organization_id_fk": { + "name": "onboarding_survey_response_organization_id_organization_id_fk", + "tableFrom": "onboarding_survey_response", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_settings": { + "name": "org_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_display_format": { + "name": "name_display_format", + "type": "name_display_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'first_last'" + }, + "date_format": { + "name": "date_format", + "type": "date_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'mdy'" + }, + "retention_enabled": { + "name": "retention_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retention_months": { + "name": "retention_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "quarantine_days": { + "name": "quarantine_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "retention_activated_at": { + "name": "retention_activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_url": { + "name": "privacy_policy_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_text": { + "name": "privacy_policy_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_contact_email": { + "name": "privacy_contact_email", + "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": { + "org_settings_organization_id_idx": { + "name": "org_settings_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_settings_organization_id_organization_id_fk": { + "name": "org_settings_organization_id_organization_id_fk", + "tableFrom": "org_settings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_ai_config": { + "name": "platform_ai_config", + "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, + "default": "'Platform (OpenRouter)'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openrouter'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai/gpt-5.4-mini'" + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "is_default_analysis": { + "name": "is_default_analysis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "platform_ai_config_organization_id_idx": { + "name": "platform_ai_config_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_ai_config_organization_id_organization_id_fk": { + "name": "platform_ai_config_organization_id_organization_id_fk", + "tableFrom": "platform_ai_config", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definition": { + "name": "property_definition", + "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": false + }, + "entity_type": { + "name": "entity_type", + "type": "property_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "property_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "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": { + "property_definition_org_idx": { + "name": "property_definition_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definition_org_entity_idx": { + "name": "property_definition_org_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definition_job_idx": { + "name": "property_definition_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definition_organization_id_organization_id_fk": { + "name": "property_definition_organization_id_organization_id_fk", + "tableFrom": "property_definition", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_definition_job_id_job_id_fk": { + "name": "property_definition_job_id_job_id_fk", + "tableFrom": "property_definition", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_value": { + "name": "property_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_definition_id": { + "name": "property_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "property_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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": { + "property_value_org_idx": { + "name": "property_value_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_entity_idx": { + "name": "property_value_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_definition_idx": { + "name": "property_value_definition_idx", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_def_entity_idx": { + "name": "property_value_def_entity_idx", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_value_organization_id_organization_id_fk": { + "name": "property_value_organization_id_organization_id_fk", + "tableFrom": "property_value", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_value_property_definition_id_property_definition_id_fk": { + "name": "property_value_property_definition_id_property_definition_id_fk", + "tableFrom": "property_value", + "tableTo": "property_definition", + "columnsFrom": [ + "property_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "public.retention_audit": { + "name": "retention_audit", + "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 + }, + "action": { + "name": "action", + "type": "retention_audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "retention_audit_organization_id_idx": { + "name": "retention_audit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_audit_candidate_id_idx": { + "name": "retention_audit_candidate_id_idx", + "columns": [ + { + "expression": "candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_audit_created_at_idx": { + "name": "retention_audit_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "retention_audit_organization_id_organization_id_fk": { + "name": "retention_audit_organization_id_organization_id_fk", + "tableFrom": "retention_audit", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scoring_criterion": { + "name": "scoring_criterion", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "criterion_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "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": { + "scoring_criterion_organization_id_idx": { + "name": "scoring_criterion_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_id_idx": { + "name": "scoring_criterion_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_key_idx": { + "name": "scoring_criterion_job_key_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scoring_criterion_organization_id_organization_id_fk": { + "name": "scoring_criterion_organization_id_organization_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scoring_criterion_job_id_job_id_fk": { + "name": "scoring_criterion_job_id_job_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracking_link": { + "name": "tracking_link", + "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": false + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "application_count": { + "name": "application_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "tracking_link_organization_id_idx": { + "name": "tracking_link_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_job_id_idx": { + "name": "tracking_link_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_code_idx": { + "name": "tracking_link_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_channel_idx": { + "name": "tracking_link_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracking_link_organization_id_organization_id_fk": { + "name": "tracking_link_organization_id_organization_id_fk", + "tableFrom": "tracking_link", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_job_id_job_id_fk": { + "name": "tracking_link_job_id_job_id_fk", + "tableFrom": "tracking_link", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_created_by_id_user_id_fk": { + "name": "tracking_link_created_by_id_user_id_fk", + "tableFrom": "tracking_link", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tracking_link_code_unique": { + "name": "tracking_link_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_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", + "scored" + ] + }, + "public.analysis_billing_mode": { + "name": "analysis_billing_mode", + "schema": "public", + "values": [ + "platform", + "byok" + ] + }, + "public.analysis_run_status": { + "name": "analysis_run_status", + "schema": "public", + "values": [ + "completed", + "failed", + "partial" + ] + }, + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "new", + "screening", + "interview", + "offer", + "hired", + "rejected" + ] + }, + "public.calendar_provider": { + "name": "calendar_provider", + "schema": "public", + "values": [ + "google" + ] + }, + "public.candidate_message_direction": { + "name": "candidate_message_direction", + "schema": "public", + "values": [ + "inbound", + "outbound" + ] + }, + "public.candidate_message_status": { + "name": "candidate_message_status", + "schema": "public", + "values": [ + "queued", + "sent", + "delivered", + "delayed", + "bounced", + "failed", + "complained" + ] + }, + "public.candidate_response": { + "name": "candidate_response", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "tentative" + ] + }, + "public.chatbot_message_role": { + "name": "chatbot_message_role", + "schema": "public", + "values": [ + "user", + "assistant" + ] + }, + "public.comment_target": { + "name": "comment_target", + "schema": "public", + "values": [ + "candidate", + "application", + "job" + ] + }, + "public.criterion_category": { + "name": "criterion_category", + "schema": "public", + "values": [ + "technical", + "experience", + "soft_skills", + "education", + "culture", + "custom" + ] + }, + "public.date_format": { + "name": "date_format", + "schema": "public", + "values": [ + "mdy", + "dmy", + "ymd" + ] + }, + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "resume", + "cover_letter", + "other" + ] + }, + "public.email_suppression_reason": { + "name": "email_suppression_reason", + "schema": "public", + "values": [ + "bounce", + "complaint" + ] + }, + "public.experience_level": { + "name": "experience_level", + "schema": "public", + "values": [ + "junior", + "mid", + "senior", + "lead" + ] + }, + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female", + "other", + "prefer_not_to_say" + ] + }, + "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.name_display_format": { + "name": "name_display_format", + "schema": "public", + "values": [ + "first_last", + "last_first" + ] + }, + "public.notification_cadence": { + "name": "notification_cadence", + "schema": "public", + "values": [ + "instant", + "digest" + ] + }, + "public.notification_channel_mode": { + "name": "notification_channel_mode", + "schema": "public", + "values": [ + "instant", + "digest", + "off" + ] + }, + "public.notification_outbox_status": { + "name": "notification_outbox_status", + "schema": "public", + "values": [ + "pending", + "sent", + "skipped", + "dead" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "candidate_replied", + "application_created", + "analysis_completed" + ] + }, + "public.property_entity_type": { + "name": "property_entity_type", + "schema": "public", + "values": [ + "candidate", + "application" + ] + }, + "public.property_type": { + "name": "property_type", + "schema": "public", + "values": [ + "text", + "long_text", + "number", + "select", + "multi_select", + "date", + "checkbox", + "url", + "email", + "person", + "file" + ] + }, + "public.question_type": { + "name": "question_type", + "schema": "public", + "values": [ + "short_text", + "long_text", + "single_select", + "multi_select", + "number", + "date", + "url", + "checkbox", + "file_upload" + ] + }, + "public.retention_audit_action": { + "name": "retention_audit_action", + "schema": "public", + "values": [ + "quarantined", + "restored", + "erased", + "exempted", + "unexempted", + "exported" + ] + }, + "public.source_channel": { + "name": "source_channel", + "schema": "public", + "values": [ + "linkedin", + "indeed", + "glassdoor", + "ziprecruiter", + "monster", + "handshake", + "angellist", + "wellfound", + "dice", + "stackoverflow", + "weworkremotely", + "remoteok", + "builtin", + "hired", + "lever", + "greenhouse_board", + "google_jobs", + "facebook", + "twitter", + "instagram", + "tiktok", + "reddit", + "referral", + "career_site", + "email", + "event", + "agency", + "direct", + "other", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/database/migrations/meta/0046_snapshot.json b/server/database/migrations/meta/0046_snapshot.json new file mode 100644 index 00000000..bf0b09e3 --- /dev/null +++ b/server/database/migrations/meta/0046_snapshot.json @@ -0,0 +1,7438 @@ +{ + "id": "10027ee8-f907-4ecb-b4c8-bcf32310f197", + "prevId": "f48bcc31-f212-4a00-a07c-5b3e07fa9309", + "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 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "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.rate_limit": { + "name": "rate_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rate_limit_key_idx": { + "name": "rate_limit_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_id_idx": { + "name": "subscription_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_stripe_customer_id_idx": { + "name": "subscription_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "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.ai_config": { + "name": "ai_config", + "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, + "default": "'Default'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gpt-4o-mini'" + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "is_default_chatbot": { + "name": "is_default_chatbot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default_analysis": { + "name": "is_default_analysis", + "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": { + "ai_config_organization_id_idx": { + "name": "ai_config_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_config_default_chatbot_idx": { + "name": "ai_config_default_chatbot_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_config\".\"is_default_chatbot\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_config_default_analysis_idx": { + "name": "ai_config_default_analysis_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ai_config\".\"is_default_analysis\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_config_organization_id_organization_id_fk": { + "name": "ai_config_organization_id_organization_id_fk", + "tableFrom": "ai_config", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analysis_run": { + "name": "analysis_run", + "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 + }, + "status": { + "name": "status", + "type": "analysis_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "criteria_snapshot": { + "name": "criteria_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "composite_score": { + "name": "composite_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd_micros": { + "name": "cost_usd_micros", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_mode": { + "name": "billing_mode", + "type": "analysis_billing_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'byok'" + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scored_by_id": { + "name": "scored_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "analysis_run_organization_id_idx": { + "name": "analysis_run_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_application_id_idx": { + "name": "analysis_run_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "analysis_run_created_at_idx": { + "name": "analysis_run_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analysis_run_organization_id_organization_id_fk": { + "name": "analysis_run_organization_id_organization_id_fk", + "tableFrom": "analysis_run", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_application_id_application_id_fk": { + "name": "analysis_run_application_id_application_id_fk", + "tableFrom": "analysis_run", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "analysis_run_scored_by_id_user_id_fk": { + "name": "analysis_run_scored_by_id_user_id_fk", + "tableFrom": "analysis_run", + "tableTo": "user", + "columnsFrom": [ + "scored_by_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.application_source": { + "name": "application_source", + "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 + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "tracking_link_id": { + "name": "tracking_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_domain": { + "name": "referrer_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_source_organization_id_idx": { + "name": "application_source_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_id_idx": { + "name": "application_source_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_channel_idx": { + "name": "application_source_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_tracking_link_id_idx": { + "name": "application_source_tracking_link_id_idx", + "columns": [ + { + "expression": "tracking_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "application_source_application_idx": { + "name": "application_source_application_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_source_organization_id_organization_id_fk": { + "name": "application_source_organization_id_organization_id_fk", + "tableFrom": "application_source", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_application_id_application_id_fk": { + "name": "application_source_application_id_application_id_fk", + "tableFrom": "application_source", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_source_tracking_link_id_tracking_link_id_fk": { + "name": "application_source_tracking_link_id_tracking_link_id_fk", + "tableFrom": "application_source", + "tableTo": "tracking_link", + "columnsFrom": [ + "tracking_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_integration": { + "name": "calendar_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "calendar_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_channel_id": { + "name": "webhook_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_resource_id": { + "name": "webhook_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_expiration": { + "name": "webhook_expiration", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_token": { + "name": "sync_token", + "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": { + "calendar_integration_user_provider_idx": { + "name": "calendar_integration_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_integration_webhook_channel_idx": { + "name": "calendar_integration_webhook_channel_idx", + "columns": [ + { + "expression": "webhook_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_integration_user_id_user_id_fk": { + "name": "calendar_integration_user_id_user_id_fk", + "tableFrom": "calendar_integration", + "tableTo": "user", + "columnsFrom": [ + "user_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 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_notes": { + "name": "quick_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_exempt_until": { + "name": "retention_exempt_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retention_exempt_reason": { + "name": "retention_exempt_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_reviewed_at": { + "name": "retention_reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scheduled_purge_at": { + "name": "scheduled_purge_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": { + "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_gender_idx": { + "name": "candidate_gender_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gender", + "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": {} + }, + "candidate_quarantine_idx": { + "name": "candidate_quarantine_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_purge_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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.candidate_conversation": { + "name": "candidate_conversation", + "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 + }, + "reply_token": { + "name": "reply_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_message_at": { + "name": "last_message_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": { + "candidate_conversation_organization_id_idx": { + "name": "candidate_conversation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_application_id_idx": { + "name": "candidate_conversation_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_reply_token_idx": { + "name": "candidate_conversation_reply_token_idx", + "columns": [ + { + "expression": "reply_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_conversation_last_message_at_idx": { + "name": "candidate_conversation_last_message_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_conversation_organization_id_organization_id_fk": { + "name": "candidate_conversation_organization_id_organization_id_fk", + "tableFrom": "candidate_conversation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_conversation_application_id_application_id_fk": { + "name": "candidate_conversation_application_id_application_id_fk", + "tableFrom": "candidate_conversation", + "tableTo": "application", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message": { + "name": "candidate_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "candidate_message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "candidate_message_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_email": { + "name": "to_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'message'" + }, + "interview_id": { + "name": "interview_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendar_attachment_status": { + "name": "calendar_attachment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "calendar_attachment_error": { + "name": "calendar_attachment_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "calendar_sequence": { + "name": "calendar_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internet_message_id": { + "name": "internet_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "references": { + "name": "references", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_by_id": { + "name": "sent_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_status_at": { + "name": "provider_status_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_message_organization_id_idx": { + "name": "candidate_message_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_conversation_id_idx": { + "name": "candidate_message_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_interview_id_idx": { + "name": "candidate_message_interview_id_idx", + "columns": [ + { + "expression": "interview_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_provider_id_idx": { + "name": "candidate_message_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_internet_id_idx": { + "name": "candidate_message_internet_id_idx", + "columns": [ + { + "expression": "internet_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_message_organization_id_organization_id_fk": { + "name": "candidate_message_organization_id_organization_id_fk", + "tableFrom": "candidate_message", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_conversation_id_candidate_conversation_id_fk": { + "name": "candidate_message_conversation_id_candidate_conversation_id_fk", + "tableFrom": "candidate_message", + "tableTo": "candidate_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_interview_id_interview_id_fk": { + "name": "candidate_message_interview_id_interview_id_fk", + "tableFrom": "candidate_message", + "tableTo": "interview", + "columnsFrom": [ + "interview_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "candidate_message_sent_by_id_user_id_fk": { + "name": "candidate_message_sent_by_id_user_id_fk", + "tableFrom": "candidate_message", + "tableTo": "user", + "columnsFrom": [ + "sent_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message_attachment": { + "name": "candidate_message_attachment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "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": true + }, + "provider_attachment_id": { + "name": "provider_attachment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_message_attachment_organization_id_idx": { + "name": "candidate_message_attachment_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_attachment_message_id_idx": { + "name": "candidate_message_attachment_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "candidate_message_attachment_organization_id_organization_id_fk": { + "name": "candidate_message_attachment_organization_id_organization_id_fk", + "tableFrom": "candidate_message_attachment", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "candidate_message_attachment_message_id_candidate_message_id_fk": { + "name": "candidate_message_attachment_message_id_candidate_message_id_fk", + "tableFrom": "candidate_message_attachment", + "tableTo": "candidate_message", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "candidate_message_attachment_storage_key_unique": { + "name": "candidate_message_attachment_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + }, + "candidate_message_attachment_provider_attachment_id_unique": { + "name": "candidate_message_attachment_provider_attachment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_attachment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate_message_webhook_event": { + "name": "candidate_message_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_message_webhook_provider_id_idx": { + "name": "candidate_message_webhook_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "candidate_message_webhook_unprocessed_idx": { + "name": "candidate_message_webhook_unprocessed_idx", + "columns": [ + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.career_page": { + "name": "career_page", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_storage_key": { + "name": "logo_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_storage_key": { + "name": "banner_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_position": { + "name": "banner_position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "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": { + "career_page_organization_id_idx": { + "name": "career_page_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "career_page_slug_idx": { + "name": "career_page_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "career_page_organization_id_organization_id_fk": { + "name": "career_page_organization_id_organization_id_fk", + "tableFrom": "career_page", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_agent": { + "name": "chatbot_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "temperature": { + "name": "temperature", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "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": { + "chatbot_agent_org_user_idx": { + "name": "chatbot_agent_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_agent_default_per_user_idx": { + "name": "chatbot_agent_default_per_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chatbot_agent\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_agent_organization_id_organization_id_fk": { + "name": "chatbot_agent_organization_id_organization_id_fk", + "tableFrom": "chatbot_agent", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_agent_user_id_user_id_fk": { + "name": "chatbot_agent_user_id_user_id_fk", + "tableFrom": "chatbot_agent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_conversation": { + "name": "chatbot_conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_config_id": { + "name": "ai_config_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New chat'" + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "thinking": { + "name": "thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_message_preview": { + "name": "last_message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_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": { + "chatbot_conversation_org_user_idx": { + "name": "chatbot_conversation_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_conversation_folder_idx": { + "name": "chatbot_conversation_folder_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatbot_conversation_last_message_at_idx": { + "name": "chatbot_conversation_last_message_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_conversation_organization_id_organization_id_fk": { + "name": "chatbot_conversation_organization_id_organization_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_conversation_user_id_user_id_fk": { + "name": "chatbot_conversation_user_id_user_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_conversation_folder_id_chatbot_folder_id_fk": { + "name": "chatbot_conversation_folder_id_chatbot_folder_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "chatbot_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chatbot_conversation_agent_id_chatbot_agent_id_fk": { + "name": "chatbot_conversation_agent_id_chatbot_agent_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "chatbot_agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chatbot_conversation_ai_config_id_ai_config_id_fk": { + "name": "chatbot_conversation_ai_config_id_ai_config_id_fk", + "tableFrom": "chatbot_conversation", + "tableTo": "ai_config", + "columnsFrom": [ + "ai_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_folder": { + "name": "chatbot_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "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": { + "chatbot_folder_org_user_idx": { + "name": "chatbot_folder_org_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_folder_organization_id_organization_id_fk": { + "name": "chatbot_folder_organization_id_organization_id_fk", + "tableFrom": "chatbot_folder", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_folder_user_id_user_id_fk": { + "name": "chatbot_folder_user_id_user_id_fk", + "tableFrom": "chatbot_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chatbot_message": { + "name": "chatbot_message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "chatbot_message_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatbot_message_conversation_idx": { + "name": "chatbot_message_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chatbot_message_conversation_id_chatbot_conversation_id_fk": { + "name": "chatbot_message_conversation_id_chatbot_conversation_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "chatbot_conversation", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_message_organization_id_organization_id_fk": { + "name": "chatbot_message_organization_id_organization_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chatbot_message_user_id_user_id_fk": { + "name": "chatbot_message_user_id_user_id_fk", + "tableFrom": "chatbot_message", + "tableTo": "user", + "columnsFrom": [ + "user_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.criterion_score": { + "name": "criterion_score", + "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 + }, + "criterion_key": { + "name": "criterion_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "applicant_score": { + "name": "applicant_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strengths": { + "name": "strengths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gaps": { + "name": "gaps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "criterion_score_organization_id_idx": { + "name": "criterion_score_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_application_id_idx": { + "name": "criterion_score_application_id_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "criterion_score_app_criterion_idx": { + "name": "criterion_score_app_criterion_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "criterion_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "criterion_score_organization_id_organization_id_fk": { + "name": "criterion_score_organization_id_organization_id_fk", + "tableFrom": "criterion_score", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "criterion_score_application_id_application_id_fk": { + "name": "criterion_score_application_id_application_id_fk", + "tableFrom": "criterion_score", + "tableTo": "application", + "columnsFrom": [ + "application_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_suppression": { + "name": "email_suppression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "email_suppression_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_suppression_email_idx": { + "name": "email_suppression_email_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "personal_note": { + "name": "personal_note", + "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 + }, + "candidate_response": { + "name": "candidate_response", + "type": "candidate_response", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "candidate_responded_at": { + "name": "candidate_responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_id": { + "name": "google_calendar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_calendar_event_link": { + "name": "google_calendar_event_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "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 + }, + "salary_negotiable": { + "name": "salary_negotiable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_status": { + "name": "remote_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valid_through": { + "name": "valid_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "experience_level": { + "name": "experience_level", + "type": "experience_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "phone_requirement": { + "name": "phone_requirement", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'optional'" + }, + "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 + }, + "auto_score_on_apply": { + "name": "auto_score_on_apply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "analysis_context": { + "name": "analysis_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"coverLetter\":true,\"screeningAnswers\":true,\"recruiterNotes\":false}'::jsonb" + }, + "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.notification_outbox": { + "name": "notification_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "notification_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "digest_bucket": { + "name": "digest_bucket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": { + "notification_outbox_dedupe_key_idx": { + "name": "notification_outbox_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_pull_idx": { + "name": "notification_outbox_pull_idx", + "columns": [ + { + "expression": "cadence", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notification_outbox\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_provider_id_idx": { + "name": "notification_outbox_provider_id_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification_outbox\".\"provider_message_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_organization_id_idx": { + "name": "notification_outbox_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_digest_idx": { + "name": "notification_outbox_digest_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "digest_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_organization_id_organization_id_fk": { + "name": "notification_outbox_organization_id_organization_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_outbox_recipient_user_id_user_id_fk": { + "name": "notification_outbox_recipient_user_id_user_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "user", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preference": { + "name": "notification_preference", + "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 + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "channel_mode": { + "name": "channel_mode", + "type": "notification_channel_mode", + "typeSchema": "public", + "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": { + "notification_preference_user_org_type_idx": { + "name": "notification_preference_user_org_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_preference_organization_id_idx": { + "name": "notification_preference_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_preference_user_id_user_id_fk": { + "name": "notification_preference_user_id_user_id_fk", + "tableFrom": "notification_preference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_preference_organization_id_organization_id_fk": { + "name": "notification_preference_organization_id_organization_id_fk", + "tableFrom": "notification_preference", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_survey_response": { + "name": "onboarding_survey_response", + "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 + }, + "signup_plan": { + "name": "signup_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_billing": { + "name": "signup_billing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_size": { + "name": "company_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_role": { + "name": "user_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discovery_source": { + "name": "discovery_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_hiring_process": { + "name": "current_hiring_process", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expected_roles_12m": { + "name": "expected_roles_12m", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answered_count": { + "name": "answered_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "onboarding_survey_response_user_id_idx": { + "name": "onboarding_survey_response_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboarding_survey_response_organization_id_idx": { + "name": "onboarding_survey_response_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_survey_response_user_id_user_id_fk": { + "name": "onboarding_survey_response_user_id_user_id_fk", + "tableFrom": "onboarding_survey_response", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_survey_response_organization_id_organization_id_fk": { + "name": "onboarding_survey_response_organization_id_organization_id_fk", + "tableFrom": "onboarding_survey_response", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_settings": { + "name": "org_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_display_format": { + "name": "name_display_format", + "type": "name_display_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'first_last'" + }, + "date_format": { + "name": "date_format", + "type": "date_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'mdy'" + }, + "retention_enabled": { + "name": "retention_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retention_months": { + "name": "retention_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "quarantine_days": { + "name": "quarantine_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "retention_activated_at": { + "name": "retention_activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_url": { + "name": "privacy_policy_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_text": { + "name": "privacy_policy_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_contact_email": { + "name": "privacy_contact_email", + "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": { + "org_settings_organization_id_idx": { + "name": "org_settings_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_settings_organization_id_organization_id_fk": { + "name": "org_settings_organization_id_organization_id_fk", + "tableFrom": "org_settings", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_ai_config": { + "name": "platform_ai_config", + "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, + "default": "'Platform (OpenRouter)'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openrouter'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai/gpt-5.4-mini'" + }, + "max_tokens": { + "name": "max_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4096 + }, + "input_price_per_1m": { + "name": "input_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "output_price_per_1m": { + "name": "output_price_per_1m", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "is_default_analysis": { + "name": "is_default_analysis", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "platform_ai_config_organization_id_idx": { + "name": "platform_ai_config_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_ai_config_organization_id_organization_id_fk": { + "name": "platform_ai_config_organization_id_organization_id_fk", + "tableFrom": "platform_ai_config", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definition": { + "name": "property_definition", + "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": false + }, + "entity_type": { + "name": "entity_type", + "type": "property_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "property_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "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": { + "property_definition_org_idx": { + "name": "property_definition_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definition_org_entity_idx": { + "name": "property_definition_org_entity_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definition_job_idx": { + "name": "property_definition_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definition_organization_id_organization_id_fk": { + "name": "property_definition_organization_id_organization_id_fk", + "tableFrom": "property_definition", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_definition_job_id_job_id_fk": { + "name": "property_definition_job_id_job_id_fk", + "tableFrom": "property_definition", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_value": { + "name": "property_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_definition_id": { + "name": "property_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "property_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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": { + "property_value_org_idx": { + "name": "property_value_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_entity_idx": { + "name": "property_value_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_definition_idx": { + "name": "property_value_definition_idx", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_value_def_entity_idx": { + "name": "property_value_def_entity_idx", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_value_organization_id_organization_id_fk": { + "name": "property_value_organization_id_organization_id_fk", + "tableFrom": "property_value", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_value_property_definition_id_property_definition_id_fk": { + "name": "property_value_property_definition_id_property_definition_id_fk", + "tableFrom": "property_value", + "tableTo": "property_definition", + "columnsFrom": [ + "property_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "public.retention_audit": { + "name": "retention_audit", + "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 + }, + "action": { + "name": "action", + "type": "retention_audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "retention_audit_organization_id_idx": { + "name": "retention_audit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_audit_candidate_id_idx": { + "name": "retention_audit_candidate_id_idx", + "columns": [ + { + "expression": "candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "retention_audit_created_at_idx": { + "name": "retention_audit_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "retention_audit_organization_id_organization_id_fk": { + "name": "retention_audit_organization_id_organization_id_fk", + "tableFrom": "retention_audit", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scoring_criterion": { + "name": "scoring_criterion", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "criterion_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "max_score": { + "name": "max_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "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": { + "scoring_criterion_organization_id_idx": { + "name": "scoring_criterion_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_id_idx": { + "name": "scoring_criterion_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scoring_criterion_job_key_idx": { + "name": "scoring_criterion_job_key_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scoring_criterion_organization_id_organization_id_fk": { + "name": "scoring_criterion_organization_id_organization_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scoring_criterion_job_id_job_id_fk": { + "name": "scoring_criterion_job_id_job_id_fk", + "tableFrom": "scoring_criterion", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracking_link": { + "name": "tracking_link", + "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": false + }, + "channel": { + "name": "channel", + "type": "source_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "click_count": { + "name": "click_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "application_count": { + "name": "application_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "tracking_link_organization_id_idx": { + "name": "tracking_link_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_job_id_idx": { + "name": "tracking_link_job_id_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_code_idx": { + "name": "tracking_link_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracking_link_channel_idx": { + "name": "tracking_link_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracking_link_organization_id_organization_id_fk": { + "name": "tracking_link_organization_id_organization_id_fk", + "tableFrom": "tracking_link", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_job_id_job_id_fk": { + "name": "tracking_link_job_id_job_id_fk", + "tableFrom": "tracking_link", + "tableTo": "job", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tracking_link_created_by_id_user_id_fk": { + "name": "tracking_link_created_by_id_user_id_fk", + "tableFrom": "tracking_link", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tracking_link_code_unique": { + "name": "tracking_link_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_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", + "scored" + ] + }, + "public.analysis_billing_mode": { + "name": "analysis_billing_mode", + "schema": "public", + "values": [ + "platform", + "byok" + ] + }, + "public.analysis_run_status": { + "name": "analysis_run_status", + "schema": "public", + "values": [ + "completed", + "failed", + "partial" + ] + }, + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "new", + "screening", + "interview", + "offer", + "hired", + "rejected" + ] + }, + "public.calendar_provider": { + "name": "calendar_provider", + "schema": "public", + "values": [ + "google" + ] + }, + "public.candidate_message_direction": { + "name": "candidate_message_direction", + "schema": "public", + "values": [ + "inbound", + "outbound" + ] + }, + "public.candidate_message_status": { + "name": "candidate_message_status", + "schema": "public", + "values": [ + "queued", + "sent", + "delivered", + "delayed", + "bounced", + "failed", + "complained" + ] + }, + "public.candidate_response": { + "name": "candidate_response", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "tentative" + ] + }, + "public.chatbot_message_role": { + "name": "chatbot_message_role", + "schema": "public", + "values": [ + "user", + "assistant" + ] + }, + "public.comment_target": { + "name": "comment_target", + "schema": "public", + "values": [ + "candidate", + "application", + "job" + ] + }, + "public.criterion_category": { + "name": "criterion_category", + "schema": "public", + "values": [ + "technical", + "experience", + "soft_skills", + "education", + "culture", + "custom" + ] + }, + "public.date_format": { + "name": "date_format", + "schema": "public", + "values": [ + "mdy", + "dmy", + "ymd" + ] + }, + "public.document_type": { + "name": "document_type", + "schema": "public", + "values": [ + "resume", + "cover_letter", + "other" + ] + }, + "public.email_suppression_reason": { + "name": "email_suppression_reason", + "schema": "public", + "values": [ + "bounce", + "complaint" + ] + }, + "public.experience_level": { + "name": "experience_level", + "schema": "public", + "values": [ + "junior", + "mid", + "senior", + "lead" + ] + }, + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female", + "other", + "prefer_not_to_say" + ] + }, + "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.name_display_format": { + "name": "name_display_format", + "schema": "public", + "values": [ + "first_last", + "last_first" + ] + }, + "public.notification_cadence": { + "name": "notification_cadence", + "schema": "public", + "values": [ + "instant", + "digest" + ] + }, + "public.notification_channel_mode": { + "name": "notification_channel_mode", + "schema": "public", + "values": [ + "instant", + "digest", + "off" + ] + }, + "public.notification_outbox_status": { + "name": "notification_outbox_status", + "schema": "public", + "values": [ + "pending", + "sent", + "skipped", + "dead" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "candidate_replied", + "application_created", + "analysis_completed" + ] + }, + "public.property_entity_type": { + "name": "property_entity_type", + "schema": "public", + "values": [ + "candidate", + "application" + ] + }, + "public.property_type": { + "name": "property_type", + "schema": "public", + "values": [ + "text", + "long_text", + "number", + "select", + "multi_select", + "date", + "checkbox", + "url", + "email", + "person", + "file" + ] + }, + "public.question_type": { + "name": "question_type", + "schema": "public", + "values": [ + "short_text", + "long_text", + "single_select", + "multi_select", + "number", + "date", + "url", + "checkbox", + "file_upload" + ] + }, + "public.retention_audit_action": { + "name": "retention_audit_action", + "schema": "public", + "values": [ + "quarantined", + "restored", + "erased", + "exempted", + "unexempted", + "exported" + ] + }, + "public.source_channel": { + "name": "source_channel", + "schema": "public", + "values": [ + "linkedin", + "indeed", + "glassdoor", + "ziprecruiter", + "monster", + "handshake", + "angellist", + "wellfound", + "dice", + "stackoverflow", + "weworkremotely", + "remoteok", + "builtin", + "hired", + "lever", + "greenhouse_board", + "google_jobs", + "facebook", + "twitter", + "instagram", + "tiktok", + "reddit", + "referral", + "career_site", + "email", + "event", + "agency", + "direct", + "other", + "custom" + ] + } + }, + "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 0f8bc80a..360b38a7 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -316,6 +316,20 @@ "when": 1784287195882, "tag": "0044_candidate_message_attachments", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1784529680195, + "tag": "0045_clammy_sleeper", + "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1784532824839, + "tag": "0046_far_hairball", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema/app.ts b/server/database/schema/app.ts index c88154bf..45b1be34 100644 --- a/server/database/schema/app.ts +++ b/server/database/schema/app.ts @@ -943,6 +943,104 @@ export const analysisRun = pgTable('analysis_run', { index('analysis_run_created_at_idx').on(t.createdAt), ])) +// ───────────────────────────────────────────── +// Recruiter Notifications (outbox + preferences + suppression) +// ───────────────────────────────────────────── +// +// Reliable, long-term recruiter notification engine. Domain events are written +// to `notificationOutbox` (ideally in the same tx as the triggering write so an +// event is never lost), and a scheduled worker drains it, sends via Resend, and +// retries with backoff. See server/utils/notifications/. + +/** The three v1 recruiter-facing events. New producers plug into the same engine. */ +export const notificationTypeEnum = pgEnum('notification_type', [ + 'candidate_replied', 'application_created', 'analysis_completed', +]) +/** How a single outbox row is delivered — immediately, or rolled into a daily digest. */ +export const notificationCadenceEnum = pgEnum('notification_cadence', ['instant', 'digest']) +/** Per-recipient, per-type choice. `off` suppresses the event entirely for that user. */ +export const notificationChannelModeEnum = pgEnum('notification_channel_mode', ['instant', 'digest', 'off']) +/** Outbox lifecycle: pending → sent | skipped | dead (past max retries). */ +export const notificationOutboxStatusEnum = pgEnum('notification_outbox_status', [ + 'pending', 'sent', 'skipped', 'dead', +]) +/** Why an address was suppressed — a hard bounce or a spam complaint. */ +export const emailSuppressionReasonEnum = pgEnum('email_suppression_reason', ['bounce', 'complaint']) + +/** + * Durable, at-least-once notification queue. One row per recipient per event. + * + * `dedupeKey` is `:` so re-enqueuing the same + * event is a no-op (unique index + onConflictDoNothing), while distinct recipients + * each get their own row. The worker pulls `status='pending'` rows whose + * `nextAttemptAt` has elapsed; `providerMessageId` links Resend delivery webhooks + * back to the row. + */ +export const notificationOutbox = pgTable('notification_outbox', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + recipientUserId: text('recipient_user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + recipientEmail: text('recipient_email').notNull(), + type: notificationTypeEnum('type').notNull(), + cadence: notificationCadenceEnum('cadence').notNull(), + /** Idempotency key: `:`. Globally unique. */ + dedupeKey: text('dedupe_key').notNull(), + /** Rendering context for the template (candidate name, job title, deep-link ids, …). */ + payload: jsonb('payload').$type>().notNull(), + status: notificationOutboxStatusEnum('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + nextAttemptAt: timestamp('next_attempt_at').notNull().defaultNow(), + /** Digest grouping key (YYYY-MM-DD, recipient timezone-agnostic v1). NULL for instant rows. */ + digestBucket: text('digest_bucket'), + /** Resend message id — set on send, used by the delivery webhook to update status. */ + providerMessageId: text('provider_message_id'), + sentAt: timestamp('sent_at'), + failedAt: timestamp('failed_at'), + lastError: text('last_error'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}, (t) => ([ + uniqueIndex('notification_outbox_dedupe_key_idx').on(t.dedupeKey), + // Drives cadence-specific pending-row pulls without indexing terminal rows. + index('notification_outbox_pull_idx').on(t.cadence, t.nextAttemptAt) + .where(sql`${t.status} = 'pending'`), + // Webhook lookup by provider message id (partial: only rows that have been sent). + uniqueIndex('notification_outbox_provider_id_idx').on(t.providerMessageId).where(sql`${t.providerMessageId} is not null`), + index('notification_outbox_organization_id_idx').on(t.organizationId), + index('notification_outbox_digest_idx').on(t.organizationId, t.recipientUserId, t.digestBucket), +])) + +/** + * Per-recipient, per-type delivery preference. Absent row = the sensible default + * resolved in code (candidate_replied/analysis_completed → instant, + * application_created → digest). See server/utils/notifications/recipients.ts. + */ +export const notificationPreference = pgTable('notification_preference', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), + type: notificationTypeEnum('type').notNull(), + channelMode: notificationChannelModeEnum('channel_mode').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}, (t) => ([ + uniqueIndex('notification_preference_user_org_type_idx').on(t.userId, t.organizationId, t.type), + index('notification_preference_organization_id_idx').on(t.organizationId), +])) + +/** + * Addresses we must never send to again. Fed by hard-bounce / complaint webhooks + * and checked at recipient resolution — protects long-term sender reputation. + */ +export const emailSuppression = pgTable('email_suppression', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + email: text('email').notNull(), + reason: emailSuppressionReasonEnum('reason').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}, (t) => ([ + uniqueIndex('email_suppression_email_idx').on(sql`lower(${t.email})`), +])) + // ───────────────────────────────────────────── // Relations // ───────────────────────────────────────────── diff --git a/server/tasks/notification-digest.ts b/server/tasks/notification-digest.ts new file mode 100644 index 00000000..80493874 --- /dev/null +++ b/server/tasks/notification-digest.ts @@ -0,0 +1,13 @@ +import { defineTask } from 'nitropack/runtime/task' +import { runNotificationDigest } from '../utils/notifications/dispatch' + +export default defineTask({ + meta: { + name: 'notification-digest', + description: 'Group pending digest-cadence notifications into one daily summary email per recipient', + }, + async run() { + const result = await runNotificationDigest({ source: 'scheduled_task' }) + return { result } + }, +}) diff --git a/server/tasks/notification-dispatch.ts b/server/tasks/notification-dispatch.ts new file mode 100644 index 00000000..54908820 --- /dev/null +++ b/server/tasks/notification-dispatch.ts @@ -0,0 +1,13 @@ +import { defineTask } from 'nitropack/runtime/task' +import { runNotificationDispatch } from '../utils/notifications/dispatch' + +export default defineTask({ + meta: { + name: 'notification-dispatch', + description: 'Drain the recruiter notification outbox and send instant emails via Resend', + }, + async run() { + const result = await runNotificationDispatch({ source: 'scheduled_task' }) + return { result } + }, +}) diff --git a/server/utils/ai/autoScore.ts b/server/utils/ai/autoScore.ts index b3a61f18..a6f91504 100644 --- a/server/utils/ai/autoScore.ts +++ b/server/utils/ai/autoScore.ts @@ -16,6 +16,7 @@ import { computeCostUsdMicros } from './pricing' import { captureAiGeneration } from './observability' import { extractResumeText } from '../resume-parser' import { fetchScreeningAnswers, DEFAULT_ANALYSIS_CONTEXT } from './analysisContext' +import { enqueueNotification } from '../notifications/enqueue' export async function autoScoreApplication(applicationId: string, orgId: string) { const app = await db.query.application.findFirst({ @@ -142,7 +143,7 @@ export async function autoScoreApplication(applicationId: string, orgId: string) .set({ score: compositeScore, updatedAt: new Date() }) .where(eq(application.id, applicationId)) - return tx.insert(analysisRun).values({ + const inserted = await tx.insert(analysisRun).values({ organizationId: orgId, applicationId, status: 'completed', @@ -155,6 +156,24 @@ export async function autoScoreApplication(applicationId: string, orgId: string) completionTokens: result.usage.completionTokens, costUsdMicros, }).returning({ id: analysisRun.id }) + + // Enqueue in the same tx so the notification is never lost if the run commits. + const runRow = inserted[0] + if (runRow) { + await enqueueNotification({ + organizationId: orgId, + type: 'analysis_completed', + dedupeKey: `analysis_completed:${runRow.id}`, + payload: { + applicationId, + candidateName: `${app.candidate.firstName} ${app.candidate.lastName}`.trim(), + jobTitle: app.job.title, + compositeScore, + }, + tx, + }) + } + return inserted }) captureAiGeneration({ diff --git a/server/utils/email.ts b/server/utils/email.ts index 1a2f96e0..342d86ee 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -1,6 +1,8 @@ import { Resend } from 'resend' import nodemailer from 'nodemailer' import type { Transporter } from 'nodemailer' +import { escapeHtml, renderNotificationLayout } from './notifications/templates' +import type { NotificationType } from '~~/shared/notifications' // ─── Resend client ──────────────────────────────────────────────────────────── @@ -60,19 +62,27 @@ interface EmailMessage { icsAttachment?: Buffer /** Resend-only metadata tags — silently ignored by SMTP. */ resendTags?: Array<{ name: string; value: string }> + /** Resend-only idempotency key — dedupes retried sends at the provider. */ + idempotencyKey?: string /** Message logged to console when no provider is configured (dev fallback). */ logFallback: string /** logError category used on transport failure. */ errorCategory: string } +/** Outcome of a transport send. `providerMessageId` is Resend-only (null for SMTP/console). */ +export interface SendEmailResult { + providerMessageId: string | null +} + /** * Route an outbound email through SMTP (preferred) → Resend → console fallback. * Priority: SMTP_HOST set → use SMTP. Else RESEND_API_KEY set → use Resend. * Otherwise logs the fallback message and returns (no error thrown). * Throws on transport errors so callers can decide whether to swallow them. + * Returns the Resend message id when available so callers can track delivery. */ -async function sendEmail(msg: EmailMessage): Promise { +async function sendEmail(msg: EmailMessage): Promise { const from = getFromEmail() // 1. SMTP — takes priority when SMTP_HOST is configured @@ -97,7 +107,7 @@ async function sendEmail(msg: EmailMessage): Promise { }) throw err } - return + return { providerMessageId: null } } // 2. Resend @@ -107,7 +117,7 @@ async function sendEmail(msg: EmailMessage): Promise { ? [{ filename: 'interview.ics', content: msg.icsAttachment.toString('base64'), content_type: 'text/calendar; method=REQUEST' }] : undefined - const { error } = await resend.emails.send({ + const { data, error } = await resend.emails.send({ from, to: [msg.to], subject: msg.subject, @@ -115,7 +125,7 @@ async function sendEmail(msg: EmailMessage): Promise { text: msg.text, ...(resendAttachments ? { attachments: resendAttachments } : {}), ...(msg.resendTags ? { tags: msg.resendTags } : {}), - }) + }, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined) if (error) { logError(msg.errorCategory, { @@ -124,11 +134,45 @@ async function sendEmail(msg: EmailMessage): Promise { }) throw new Error(error.message) } - return + return { providerMessageId: data?.id ?? null } } // 3. No provider configured — dev/test fallback console.info(`[Reqcore] ${msg.logFallback}`) + return { providerMessageId: null } +} + +/** + * Send a recruiter notification email. Routes through the shared `sendEmail` + * transport (SMTP → Resend → console), tagging it `category: notification` so + * the Resend delivery webhook can map events back to the outbox, and passing the + * outbox row id as the Resend idempotency key to make retried sends safe. + * Returns the provider message id for the worker to persist. + */ +export async function sendNotificationEmail(msg: { + to: string + subject: string + html: string + text: string + organizationId: string + /** Resend `type` tag — an event type, or `digest` for the daily roll-up. */ + type: NotificationType | 'digest' + idempotencyKey: string +}): Promise { + return sendEmail({ + to: msg.to, + subject: msg.subject, + html: msg.html, + text: msg.text, + idempotencyKey: msg.idempotencyKey, + resendTags: [ + { name: 'category', value: 'notification' }, + { name: 'organization', value: msg.organizationId }, + { name: 'type', value: msg.type }, + ], + logFallback: `Notification email (${msg.type}) → ${msg.to} | ${msg.subject}`, + errorCategory: 'email.notification_send_failed', + }) } export interface CandidateMessageEmailResult { @@ -328,65 +372,18 @@ function buildInvitationHtml(params: { }): string { const { inviterName, organizationName, role, inviteLink } = params - return ` - - - - - You're invited to ${escapeHtml(organizationName)} - - - - - - -
- - - - - - - - - - - - - -
-

Reqcore

-
-

You've been invited

-

- ${escapeHtml(inviterName)} has invited you to join - ${escapeHtml(organizationName)} as a ${escapeHtml(role)}. -

-

- Click the button below to accept the invitation. You'll need to sign in or create an account first. -

- - - - - -
- - Accept Invitation - -
-

- This invitation expires in 48 hours. If you didn't expect this email, you can safely ignore it. -

-
-

- Sent by Reqcore — Open-source applicant tracking -

-
-
- -` + return renderNotificationLayout({ + title: `You're invited to ${organizationName}`, + heading: "You've been invited", + bodyHtml: + `

` + + `${escapeHtml(inviterName)} has invited you to join ` + + `${escapeHtml(organizationName)} as a ${escapeHtml(role)}.

` + + `

` + + `Click the button below to accept the invitation. You'll need to sign in or create an account first.

`, + cta: { label: 'Accept Invitation', url: inviteLink }, + note: "This invitation expires in 48 hours. If you didn't expect this email, you can safely ignore it.", + }) } function buildInvitationText(params: { @@ -410,72 +407,20 @@ function buildInvitationText(params: { ].join('\n') } -/** - * Escape HTML special characters to prevent XSS in email templates. - */ -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - // ───────────────────────────────────────────── // Email verification & password reset templates // ───────────────────────────────────────────── function buildVerificationHtml(params: { url: string }): string { - return ` - - - - - Verify your email - - - - - - -
- - - - - - - - - - -
-

Reqcore

-
-

Verify your email

-

- Click the button below to verify your email address and activate your account. -

- - - - -
- - Verify Email - -
-

- If you didn't create an account, you can safely ignore this email. -

-
-

Sent by Reqcore — Open-source applicant tracking

-
-
- -` + return renderNotificationLayout({ + title: 'Verify your email', + heading: 'Verify your email', + bodyHtml: + `

` + + `Click the button below to verify your email address and activate your account.

`, + cta: { label: 'Verify Email', url: params.url }, + note: "If you didn't create an account, you can safely ignore this email.", + }) } function buildVerificationText(params: { url: string }): string { @@ -492,55 +437,15 @@ function buildVerificationText(params: { url: string }): string { } function buildPasswordResetHtml(params: { url: string }): string { - return ` - - - - - Reset your password - - - - - - -
- - - - - - - - - - -
-

Reqcore

-
-

Reset your password

-

- Click the button below to reset your password. This link will expire shortly. -

- - - - -
- - Reset Password - -
-

- If you didn't request a password reset, you can safely ignore this email. -

-
-

Sent by Reqcore — Open-source applicant tracking

-
-
- -` + return renderNotificationLayout({ + title: 'Reset your password', + heading: 'Reset your password', + bodyHtml: + `

` + + `Click the button below to reset your password. This link will expire shortly.

`, + cta: { label: 'Reset Password', url: params.url }, + note: "If you didn't request a password reset, you can safely ignore this email.", + }) } function buildPasswordResetText(params: { url: string }): string { diff --git a/server/utils/env.ts b/server/utils/env.ts index d8a00390..99f0bdef 100644 --- a/server/utils/env.ts +++ b/server/utils/env.ts @@ -176,6 +176,16 @@ export const envSchema = z val => val === undefined || val === '' ? false : val === true || val === 'true', z.boolean().default(false), ), + /** + * Kill-switch for the recruiter notification engine. Fail-safe default = ON: + * notifications are an activation driver and are safe to send. Set to false to + * pause all outbox draining/digests instance-wide (checked at the top of the + * dispatch + digest workers). Mirrors the GDPR_CLEANUP_ENABLED idiom. + */ + NOTIFICATIONS_ENABLED: z.preprocess( + val => val === undefined || val === '' ? true : val === true || val === 'true', + z.boolean().default(true), + ), // ── Platform AI gateway (optional) ────────────────────────── // When OPENROUTER_API_KEY is set, orgs without their own AI config fall back // to our platform key, routed through OpenRouter for unified billing and diff --git a/server/utils/notifications/delivery-status.ts b/server/utils/notifications/delivery-status.ts new file mode 100644 index 00000000..9e87f890 --- /dev/null +++ b/server/utils/notifications/delivery-status.ts @@ -0,0 +1,82 @@ +import type { WebhookEventPayload } from 'resend' +import { and, eq } from 'drizzle-orm' +import { emailSuppression, notificationOutbox } from '../../database/schema' + +const NOTIFICATION_STATUS_EVENTS = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.bounced', + 'email.failed', + 'email.complained', +] as const + +export type NotificationStatusEvent = Extract< + WebhookEventPayload, + { type: (typeof NOTIFICATION_STATUS_EVENTS)[number] } +> + +function statusError(event: NotificationStatusEvent): { code: string, message: string } | null { + if (event.type === 'email.bounced') { + return { code: event.data.bounce.type, message: event.data.bounce.message } + } + if (event.type === 'email.failed') { + return { code: 'failed', message: event.data.failed.reason } + } + if (event.type === 'email.complained') { + return { code: 'complained', message: 'The recipient marked this message as spam' } + } + return null +} + +function suppressionForEvent(event: NotificationStatusEvent): 'bounce' | 'complaint' | null { + if (event.type === 'email.complained') return 'complaint' + if (event.type === 'email.bounced') { + const type = event.data.bounce.type?.toLowerCase() ?? '' + if (type.includes('hard') || type.includes('permanent')) return 'bounce' + } + return null +} + +export async function processNotificationStatusEvent(event: NotificationStatusEvent): Promise { + const eventAt = new Date(event.created_at) + const outboxRow = await db.query.notificationOutbox.findFirst({ + where: eq(notificationOutbox.providerMessageId, event.data.email_id), + columns: { + id: true, + organizationId: true, + recipientUserId: true, + recipientEmail: true, + cadence: true, + digestBucket: true, + }, + }) + + if (!outboxRow) return + + const identity = outboxRow.cadence === 'digest' && outboxRow.digestBucket + ? and( + eq(notificationOutbox.organizationId, outboxRow.organizationId), + eq(notificationOutbox.recipientUserId, outboxRow.recipientUserId), + eq(notificationOutbox.recipientEmail, outboxRow.recipientEmail), + eq(notificationOutbox.digestBucket, outboxRow.digestBucket), + ) + : eq(notificationOutbox.id, outboxRow.id) + + const isFailure = event.type === 'email.bounced' + || event.type === 'email.failed' + || event.type === 'email.complained' + const error = statusError(event) + await db.update(notificationOutbox).set({ + ...(isFailure ? { status: 'dead' as const, failedAt: eventAt } : {}), + ...(error ? { lastError: `${error.code}: ${error.message}`.slice(0, 1000) } : {}), + updatedAt: new Date(), + }).where(identity) + + const suppression = suppressionForEvent(event) + if (suppression) { + await db.insert(emailSuppression) + .values({ email: outboxRow.recipientEmail.trim().toLowerCase(), reason: suppression }) + .onConflictDoNothing() + } +} diff --git a/server/utils/notifications/dispatch.ts b/server/utils/notifications/dispatch.ts new file mode 100644 index 00000000..53e32dd1 --- /dev/null +++ b/server/utils/notifications/dispatch.ts @@ -0,0 +1,344 @@ +/** Durable notification outbox dispatch with leases, retries, and digest grouping. */ +import { and, asc, eq, gt, inArray, lte, sql } from 'drizzle-orm' +import { emailSuppression, notificationOutbox } from '../../database/schema/app' +import { isServerFeatureEnabled } from '../featureFlags' +import { sendNotificationEmail } from '../email' +import { renderDigest, renderNotification } from './templates' +import { applicationUrl, dashboardUrl, inboxConversationUrl } from './urls' +import type { NotificationType } from '~~/shared/notifications' + +const MAX_ATTEMPTS = 6 +const DEFAULT_BATCH = 50 +const LEASE_MS = 10 * 60_000 + +type OutboxRow = typeof notificationOutbox.$inferSelect +type NotificationDbClient = typeof db | Parameters[0]>[0] + +export interface NotificationDispatchResult { + enabled: boolean + source: NotificationDispatchSource + processed: number + sent: number + skipped: number + retried: number + dead: number +} + +export type NotificationDispatchSource = 'scheduled_task' | 'cron_endpoint' | 'interactive' + +function clampBatch(n: number | undefined): number { + return Math.min(500, Math.max(1, n ?? DEFAULT_BATCH)) +} + +function backoffMs(attempts: number): number { + return Math.min(60_000 * 2 ** Math.max(0, attempts - 1), 60 * 60_000) +} + +function disabledResult(source: NotificationDispatchSource): NotificationDispatchResult { + return { enabled: false, source, processed: 0, sent: 0, skipped: 0, retried: 0, dead: 0 } +} + +function emptyResult(source: NotificationDispatchSource): NotificationDispatchResult { + return { enabled: true, source, processed: 0, sent: 0, skipped: 0, retried: 0, dead: 0 } +} + +function mergeResults(a: NotificationDispatchResult, b: NotificationDispatchResult): NotificationDispatchResult { + return { + enabled: a.enabled && b.enabled, + source: a.source, + processed: a.processed + b.processed, + sent: a.sent + b.sent, + skipped: a.skipped + b.skipped, + retried: a.retried + b.retried, + dead: a.dead + b.dead, + } +} + +function rowApplicationUrl(row: OutboxRow): string { + const payload = row.payload as Record + if (row.type === 'candidate_replied' && payload.conversationId) { + return inboxConversationUrl(String(payload.conversationId)) + } + return applicationUrl(String(payload.applicationId ?? '')) +} + +function enrichPayload(row: OutboxRow): Record { + return { ...(row.payload as Record), applicationUrl: rowApplicationUrl(row) } +} + +async function featureEnabled(organizationId: string): Promise { + return isServerFeatureEnabled('notifications', { + distinctId: `notification-org:${organizationId}`, + groups: { organization: organizationId }, + }) +} + +async function isSuppressed(email: string, dbc: NotificationDbClient = db): Promise { + const rows = await dbc.select({ id: emailSuppression.id }) + .from(emailSuppression) + .where(eq(sql`lower(${emailSuppression.email})`, email.toLowerCase())) + .limit(1) + return rows.length > 0 +} + +async function markSent(id: string, providerMessageId: string | null): Promise { + await db.update(notificationOutbox) + .set({ status: 'sent', providerMessageId, sentAt: new Date(), lastError: null, updatedAt: new Date() }) + .where(eq(notificationOutbox.id, id)) +} + +async function markSkipped(ids: string[], reason: string): Promise { + if (ids.length === 0) return + await db.update(notificationOutbox) + .set({ status: 'skipped', lastError: reason, updatedAt: new Date() }) + .where(inArray(notificationOutbox.id, ids)) +} + +async function recordFailure(row: OutboxRow, err: unknown): Promise<'dead' | 'retried'> { + const attempts = row.attempts + 1 + const message = (err instanceof Error ? err.message : String(err)).slice(0, 1000) + if (attempts >= MAX_ATTEMPTS) { + await db.update(notificationOutbox) + .set({ status: 'dead', attempts, lastError: message, failedAt: new Date(), updatedAt: new Date() }) + .where(eq(notificationOutbox.id, row.id)) + logError('notification.dispatch_dead', { outbox_id: row.id, type: row.type, attempts }) + return 'dead' + } + await db.update(notificationOutbox) + .set({ attempts, nextAttemptAt: new Date(Date.now() + backoffMs(attempts)), lastError: message, updatedAt: new Date() }) + .where(eq(notificationOutbox.id, row.id)) + return 'retried' +} + +async function claimInstantRows(batchSize: number, now: Date): Promise { + const candidates = await db.select({ id: notificationOutbox.id }).from(notificationOutbox) + .where(and( + eq(notificationOutbox.status, 'pending'), + eq(notificationOutbox.cadence, 'instant'), + lte(notificationOutbox.nextAttemptAt, now), + )) + .orderBy(asc(notificationOutbox.nextAttemptAt)) + .limit(batchSize) + + const claimed: OutboxRow[] = [] + for (const candidate of candidates) { + const [row] = await db.update(notificationOutbox) + .set({ nextAttemptAt: new Date(now.getTime() + LEASE_MS), updatedAt: now }) + .where(and( + eq(notificationOutbox.id, candidate.id), + eq(notificationOutbox.status, 'pending'), + lte(notificationOutbox.nextAttemptAt, now), + )) + .returning() + if (row) claimed.push(row) + } + return claimed +} + +async function dispatchInstant(source: NotificationDispatchSource, batchSize: number): Promise { + const rows = await claimInstantRows(batchSize, new Date()) + const result = emptyResult(source) + result.processed = rows.length + + const flags = new Map() + for (const row of rows) { + let enabled = flags.get(row.organizationId) + if (enabled === undefined) { + enabled = await featureEnabled(row.organizationId) + flags.set(row.organizationId, enabled) + } + if (!enabled) { + await markSkipped([row.id], 'feature_disabled') + result.skipped++ + continue + } + if (await isSuppressed(row.recipientEmail)) { + await markSkipped([row.id], 'email_suppressed') + result.skipped++ + continue + } + + const rendered = renderNotification(row.type as NotificationType, enrichPayload(row)) + if (!rendered) { + await markSkipped([row.id], 'unrenderable_payload') + result.skipped++ + continue + } + try { + const { providerMessageId } = await sendNotificationEmail({ + to: row.recipientEmail, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + organizationId: row.organizationId, + type: row.type as NotificationType, + idempotencyKey: row.id, + }) + await markSent(row.id, providerMessageId) + result.sent++ + } + catch (err) { + const outcome = await recordFailure(row, err) + outcome === 'dead' ? result.dead++ : result.retried++ + } + } + return result +} + +interface DigestGroupKey { + organizationId: string + recipientUserId: string + recipientEmail: string + digestBucket: string | null +} + +async function claimDigestGroup(key: DigestGroupKey, now: Date, retryOnly: boolean): Promise { + if (!key.digestBucket) return [] + return db.update(notificationOutbox) + .set({ nextAttemptAt: new Date(now.getTime() + LEASE_MS), updatedAt: now }) + .where(and( + eq(notificationOutbox.organizationId, key.organizationId), + eq(notificationOutbox.recipientUserId, key.recipientUserId), + eq(notificationOutbox.recipientEmail, key.recipientEmail), + eq(notificationOutbox.digestBucket, key.digestBucket), + eq(notificationOutbox.status, 'pending'), + eq(notificationOutbox.cadence, 'digest'), + lte(notificationOutbox.nextAttemptAt, now), + ...(retryOnly ? [gt(notificationOutbox.attempts, 0)] : []), + )) + .returning() +} + +async function markDigestSent(rows: OutboxRow[], providerMessageId: string | null): Promise { + const ids = rows.map(row => row.id) + const leader = rows[0] + if (!leader) return + await db.transaction(async (tx) => { + await tx.update(notificationOutbox) + .set({ status: 'sent', providerMessageId: null, sentAt: new Date(), lastError: null, updatedAt: new Date() }) + .where(inArray(notificationOutbox.id, ids)) + if (providerMessageId) { + await tx.update(notificationOutbox) + .set({ providerMessageId }) + .where(eq(notificationOutbox.id, leader.id)) + } + }) +} + +async function dispatchDigest( + source: NotificationDispatchSource, + batchSize: number, + retryOnly: boolean, +): Promise { + const now = new Date() + const groups = await db.select({ + organizationId: notificationOutbox.organizationId, + recipientUserId: notificationOutbox.recipientUserId, + recipientEmail: notificationOutbox.recipientEmail, + digestBucket: notificationOutbox.digestBucket, + }).from(notificationOutbox) + .where(and( + eq(notificationOutbox.status, 'pending'), + eq(notificationOutbox.cadence, 'digest'), + lte(notificationOutbox.nextAttemptAt, now), + ...(retryOnly ? [gt(notificationOutbox.attempts, 0)] : []), + )) + .groupBy( + notificationOutbox.organizationId, + notificationOutbox.recipientUserId, + notificationOutbox.recipientEmail, + notificationOutbox.digestBucket, + ) + .orderBy(asc(notificationOutbox.digestBucket)) + .limit(batchSize) + + const result = emptyResult(source) + const flags = new Map() + for (const key of groups) { + const rows = await claimDigestGroup(key, now, retryOnly) + if (rows.length === 0 || !key.digestBucket) continue + result.processed += rows.length + + let enabled = flags.get(key.organizationId) + if (enabled === undefined) { + enabled = await featureEnabled(key.organizationId) + flags.set(key.organizationId, enabled) + } + if (!enabled) { + await markSkipped(rows.map(row => row.id), 'feature_disabled') + result.skipped += rows.length + continue + } + if (await isSuppressed(key.recipientEmail)) { + await markSkipped(rows.map(row => row.id), 'email_suppressed') + result.skipped += rows.length + continue + } + + const rendered = renderDigest({ + items: rows.map(row => ({ + type: row.type as NotificationType, + payload: row.payload as Record, + applicationUrl: rowApplicationUrl(row), + })), + dashboardUrl: dashboardUrl(), + }) + if (!rendered) { + await markSkipped(rows.map(row => row.id), 'unrenderable_payload') + result.skipped += rows.length + continue + } + try { + const { providerMessageId } = await sendNotificationEmail({ + to: key.recipientEmail, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + organizationId: key.organizationId, + type: 'digest', + idempotencyKey: `digest:${key.organizationId}:${key.recipientUserId}:${key.digestBucket}`, + }) + await markDigestSent(rows, providerMessageId) + result.sent++ + } + catch (err) { + result.retried++ + for (const row of rows) { + const outcome = await recordFailure(row, err) + if (outcome === 'dead') result.dead++ + } + } + } + return result +} + +export async function runNotificationDispatch( + options: { source?: NotificationDispatchSource, batchSize?: number } = {}, +): Promise { + const source = options.source ?? 'scheduled_task' + if (!env.NOTIFICATIONS_ENABLED) { + logWarn('notification.dispatch_disabled', { source }) + return disabledResult(source) + } + + const batchSize = clampBatch(options.batchSize) + const instant = await dispatchInstant(source, batchSize) + const digestRetries = await dispatchDigest(source, batchSize, true) + const result = mergeResults(instant, digestRetries) + logInfo('notification.dispatch_completed', { ...result }) + return result +} + +export async function runNotificationDigest( + options: { source?: NotificationDispatchSource, batchSize?: number } = {}, +): Promise { + const source = options.source ?? 'scheduled_task' + if (!env.NOTIFICATIONS_ENABLED) { + logWarn('notification.digest_disabled', { source }) + return disabledResult(source) + } + + const result = await dispatchDigest(source, clampBatch(options.batchSize ?? 500), false) + logInfo('notification.digest_completed', { ...result }) + return result +} diff --git a/server/utils/notifications/enqueue.ts b/server/utils/notifications/enqueue.ts new file mode 100644 index 00000000..e8205cc7 --- /dev/null +++ b/server/utils/notifications/enqueue.ts @@ -0,0 +1,69 @@ +/** + * Enqueue a recruiter notification event into the durable outbox. + * + * Modeled on `recordActivity` — it never throws, so a notification failure can + * never break the primary write. Accepts an optional transaction so critical + * events (e.g. analysis completion) are enqueued atomically with the triggering + * write: if the tx rolls back, the outbox rows roll back with it. + * + * Idempotency: each recipient row's `dedupeKey` is `:`, and + * inserts use `onConflictDoNothing`, so re-running the same producer is a no-op. + */ +import { notificationOutbox } from '../../database/schema/app' +import type { NotificationType } from '~~/shared/notifications' +import { resolveRecipients, type NotificationDbClient } from './recipients' + +const DIGEST_HOUR_UTC = 8 + +/** The next 08:00 UTC digest delivery date (YYYY-MM-DD). */ +export function digestBucketFor(date: Date = new Date()): string { + const cutoff = new Date(date) + cutoff.setUTCHours(DIGEST_HOUR_UTC, 0, 0, 0) + if (date >= cutoff) cutoff.setUTCDate(cutoff.getUTCDate() + 1) + return cutoff.toISOString().slice(0, 10) +} + +export function digestDeliveryAt(bucket: string): Date { + return new Date(`${bucket}T${String(DIGEST_HOUR_UTC).padStart(2, '0')}:00:00.000Z`) +} + +export async function enqueueNotification(params: { + organizationId: string + type: NotificationType + /** Event-scoped idempotency prefix, e.g. `application_created:`. */ + dedupeKey: string + payload: Record + tx?: NotificationDbClient +}): Promise { + const dbc = params.tx ?? db + try { + const recipients = await resolveRecipients(params.organizationId, params.type, dbc) + if (recipients.length === 0) return + + const bucket = digestBucketFor() + const rows = recipients.map(r => ({ + organizationId: params.organizationId, + recipientUserId: r.userId, + recipientEmail: r.email, + type: params.type, + cadence: r.cadence, + dedupeKey: `${params.dedupeKey}:${r.userId}`, + payload: params.payload, + digestBucket: r.cadence === 'digest' ? bucket : null, + nextAttemptAt: r.cadence === 'digest' ? digestDeliveryAt(bucket) : new Date(), + })) + + await dbc.insert(notificationOutbox).values(rows) + .onConflictDoNothing({ target: notificationOutbox.dedupeKey }) + } + catch (err) { + if (params.tx) throw err + // Enqueue must never break the primary operation. + logWarn('notification.enqueue_failed', { + organization_id: params.organizationId, + type: params.type, + dedupe_key: params.dedupeKey, + error_message: err instanceof Error ? err.message : String(err), + }) + } +} diff --git a/server/utils/notifications/recipients.ts b/server/utils/notifications/recipients.ts new file mode 100644 index 00000000..a6af98de --- /dev/null +++ b/server/utils/notifications/recipients.ts @@ -0,0 +1,65 @@ +/** + * Resolve which org members should receive a given notification event, honoring + * per-recipient preferences and the email suppression list. + */ +import { and, eq, inArray, sql } from 'drizzle-orm' +import { member, user } from '../../database/schema/auth' +import { emailSuppression, notificationPreference } from '../../database/schema/app' +import { + DEFAULT_CHANNEL_MODE, + type NotificationCadence, + type NotificationType, +} from '~~/shared/notifications' + +/** Either the global db or an open transaction — both expose the query/select API we use. */ +type Tx = Parameters[0]>[0] +export type NotificationDbClient = typeof db | Tx + +export interface NotificationRecipient { + userId: string + email: string + cadence: NotificationCadence +} + +/** + * Return the members of `organizationId` who want `type` delivered (mode !== 'off' + * after applying defaults), excluding any address on the suppression list. + */ +export async function resolveRecipients( + organizationId: string, + type: NotificationType, + dbc: NotificationDbClient = db, +): Promise { + const members = await dbc + .select({ userId: member.userId, email: user.email }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + + if (members.length === 0) return [] + + const prefs = await dbc + .select({ userId: notificationPreference.userId, channelMode: notificationPreference.channelMode }) + .from(notificationPreference) + .where(and( + eq(notificationPreference.organizationId, organizationId), + eq(notificationPreference.type, type), + )) + const prefByUser = new Map(prefs.map(p => [p.userId, p.channelMode])) + + const emails = members.map(m => m.email.toLowerCase()) + const suppressed = await dbc + .select({ email: emailSuppression.email }) + .from(emailSuppression) + .where(inArray(sql`lower(${emailSuppression.email})`, emails)) + const suppressedSet = new Set(suppressed.map(s => s.email.toLowerCase())) + + const recipients: NotificationRecipient[] = [] + for (const m of members) { + if (suppressedSet.has(m.email.toLowerCase())) continue + const mode = prefByUser.get(m.userId) ?? DEFAULT_CHANNEL_MODE[type] + if (mode === 'off') continue + recipients.push({ userId: m.userId, email: m.email, cadence: mode }) + } + return recipients +} diff --git a/server/utils/notifications/templates.ts b/server/utils/notifications/templates.ts new file mode 100644 index 00000000..287caaee --- /dev/null +++ b/server/utils/notifications/templates.ts @@ -0,0 +1,333 @@ +/** + * Recruiter notification email templates. + * + * `renderNotificationLayout` is the single shared HTML shell (header + card + + * footer) that the auth/invitation emails in `email.ts` also reuse — extracting + * it here pays down the verbatim duplication those three builders used to carry. + * + * Each event has one content builder returning `{ subject, html, text }`, and + * `renderNotification` dispatches by type. All untrusted candidate/job strings + * flow through `escapeHtml` before reaching the HTML. + */ +import { z } from 'zod' +import type { NotificationType } from '~~/shared/notifications' + +export interface RenderedEmail { + subject: string + html: string + text: string +} + +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Shared responsive email shell. `bodyHtml` is trusted, pre-built markup; + * callers are responsible for escaping any untrusted values they interpolate. + */ +export function renderNotificationLayout(params: { + title: string + heading: string + bodyHtml: string + cta?: { label: string, url: string } + /** Small muted paragraph rendered below the CTA (already-escaped/plain text). */ + note?: string + footer?: string +}): string { + const { title, heading, bodyHtml, cta, note } = params + const footer = params.footer ?? 'Sent by Reqcore — Open-source applicant tracking' + + const noteHtml = note + ? `

${escapeHtml(note)}

` + : '' + + const ctaHtml = cta + ? ` + + + +
+ + ${escapeHtml(cta.label)} + +
` + : '' + + return ` + + + + + ${escapeHtml(title)} + + + + + + +
+ + + + + + + + + + +
+

Reqcore

+
+

${escapeHtml(heading)}

+ ${bodyHtml} + ${ctaHtml} + ${noteHtml} +
+

${footer}

+
+
+ +` +} + +// ───────────────────────────────────────────── +// Per-event payloads + content builders +// ───────────────────────────────────────────── + +export interface ApplicationCreatedPayload { + candidateName: string + jobTitle: string + applicationUrl: string +} + +export interface AnalysisCompletedPayload { + candidateName: string + jobTitle: string + /** Composite 0–100 score, or null when the run produced none. */ + compositeScore: number | null + applicationUrl: string +} + +export interface CandidateRepliedPayload { + candidateName: string + jobTitle: string + /** Short preview of the reply, already trimmed by the producer. */ + preview: string + applicationUrl: string +} + +export interface NotificationPayloads { + application_created: ApplicationCreatedPayload + analysis_completed: AnalysisCompletedPayload + candidate_replied: CandidateRepliedPayload +} + +const basePayloadSchema = z.object({ + candidateName: z.string().min(1), + jobTitle: z.string().min(1), + applicationUrl: z.string().url(), +}) + +const payloadSchemas = { + application_created: basePayloadSchema, + analysis_completed: basePayloadSchema.extend({ + compositeScore: z.number().finite().min(0).max(100).nullable(), + }), + candidate_replied: basePayloadSchema.extend({ preview: z.string() }), +} as const + +function parsePayload( + type: T, + payload: Record, +): NotificationPayloads[T] | null { + const result = payloadSchemas[type].safeParse(payload) + return result.success ? result.data as NotificationPayloads[T] : null +} + +function paragraph(html: string): string { + return `

${html}

` +} + +function buildApplicationCreated(p: ApplicationCreatedPayload): RenderedEmail { + const subject = `New applicant for ${p.jobTitle}` + return { + subject, + html: renderNotificationLayout({ + title: subject, + heading: 'New applicant', + bodyHtml: + paragraph(`${escapeHtml(p.candidateName)} applied for ${escapeHtml(p.jobTitle)}.`) + + paragraph('Open the application to review their profile.'), + cta: { label: 'View application', url: p.applicationUrl }, + }), + text: [ + 'New applicant', + '', + `${p.candidateName} applied for ${p.jobTitle}.`, + '', + `View the application: ${p.applicationUrl}`, + '', + '— Reqcore', + ].join('\n'), + } +} + +function buildAnalysisCompleted(p: AnalysisCompletedPayload): RenderedEmail { + const scoreLabel = p.compositeScore != null ? `${p.compositeScore}/100` : 'ready' + const subject = `AI scoring finished for ${p.candidateName}` + const scoreLine = p.compositeScore != null + ? paragraph(`Composite score: ${escapeHtml(scoreLabel)}.`) + : '' + return { + subject, + html: renderNotificationLayout({ + title: subject, + heading: 'AI scoring finished', + bodyHtml: + paragraph(`${escapeHtml(p.candidateName)} was scored for ${escapeHtml(p.jobTitle)}.`) + + scoreLine + + paragraph('Open the application to see the breakdown and shortlist.'), + cta: { label: 'Review scoring', url: p.applicationUrl }, + }), + text: [ + 'AI scoring finished', + '', + `${p.candidateName} was scored for ${p.jobTitle}.`, + ...(p.compositeScore != null ? [`Composite score: ${scoreLabel}.`] : []), + '', + `Review the scoring: ${p.applicationUrl}`, + '', + '— Reqcore', + ].join('\n'), + } +} + +function buildCandidateReplied(p: CandidateRepliedPayload): RenderedEmail { + const subject = `${p.candidateName} replied` + const previewHtml = p.preview + ? `
${escapeHtml(p.preview)}
` + : '' + return { + subject, + html: renderNotificationLayout({ + title: subject, + heading: 'New reply', + bodyHtml: + paragraph(`${escapeHtml(p.candidateName)} replied about ${escapeHtml(p.jobTitle)}.`) + + previewHtml + + paragraph('Open the conversation to respond.'), + cta: { label: 'Open conversation', url: p.applicationUrl }, + }), + text: [ + 'New reply', + '', + `${p.candidateName} replied about ${p.jobTitle}.`, + ...(p.preview ? ['', p.preview] : []), + '', + `Open the conversation: ${p.applicationUrl}`, + '', + '— Reqcore', + ].join('\n'), + } +} + +/** + * Render an instant notification email for one outbox row. Returns null when the + * payload doesn't match the event's expected shape (defensive — a bad row is + * skipped, not retried forever). + */ +export function renderNotification( + type: NotificationType, + payload: Record, +): RenderedEmail | null { + switch (type) { + case 'application_created': { + const parsed = parsePayload(type, payload) + return parsed ? buildApplicationCreated(parsed) : null + } + case 'analysis_completed': { + const parsed = parsePayload(type, payload) + return parsed ? buildAnalysisCompleted(parsed) : null + } + case 'candidate_replied': { + const parsed = parsePayload(type, payload) + return parsed ? buildCandidateReplied(parsed) : null + } + default: + return null + } +} + +/** One-line summary of an event for the daily digest list. */ +export function summarizeNotification(type: NotificationType, payload: Record): string { + const p = payload as Record + const name = escapeHtml(String(p.candidateName ?? 'A candidate')) + const job = escapeHtml(String(p.jobTitle ?? 'a role')) + switch (type) { + case 'application_created': + return `${name} applied for ${job}` + case 'analysis_completed': + return `${name} was scored for ${job}${p.compositeScore != null ? ` (${escapeHtml(String(p.compositeScore))}/100)` : ''}` + case 'candidate_replied': + return `${name} replied about ${job}` + default: + return name + } +} + +/** + * Render the daily digest email grouping several pending rows for one recipient. + * `dashboardUrl` deep-links to the dashboard so the CTA always works even though + * the individual rows may point at different applications. + */ +export function renderDigest(params: { + items: Array<{ type: NotificationType, payload: Record, applicationUrl: string }> + dashboardUrl: string +}): RenderedEmail | null { + const validItems = params.items.every(item => parsePayload(item.type, { + ...item.payload, + applicationUrl: item.applicationUrl, + })) + if (!validItems || params.items.length === 0) return null + + const count = params.items.length + const subject = `Your Reqcore digest — ${count} update${count === 1 ? '' : 's'}` + const rowsHtml = params.items.map(item => + `` + + `${summarizeNotification(item.type, item.payload)}` + + ``, + ).join('') + const rowsText = params.items.map((item) => { + const p = item.payload as Record + return `• ${String(p.candidateName ?? 'A candidate')} — ${String(p.jobTitle ?? 'a role')}\n ${item.applicationUrl}` + }).join('\n') + + return { + subject, + html: renderNotificationLayout({ + title: subject, + heading: `${count} update${count === 1 ? '' : 's'} today`, + bodyHtml: + `

Here's what happened across your roles.

` + + `${rowsHtml}
`, + cta: { label: 'Open dashboard', url: params.dashboardUrl }, + }), + text: [ + `${count} update${count === 1 ? '' : 's'} today`, + '', + rowsText, + '', + `Open dashboard: ${params.dashboardUrl}`, + '', + '— Reqcore', + ].join('\n'), + } +} diff --git a/server/utils/notifications/urls.ts b/server/utils/notifications/urls.ts new file mode 100644 index 00000000..dff9f341 --- /dev/null +++ b/server/utils/notifications/urls.ts @@ -0,0 +1,27 @@ +/** + * Dashboard deep-link helpers for notification emails. Mirrors the base-URL + * resolution used elsewhere (interview-conversation, google-calendar): explicit + * BETTER_AUTH_URL wins, then the Railway public domain, then the marketing URL. + * + * Producers persist only ids in the outbox payload; the worker builds the full + * URL at send time so links stay correct even if the deployment domain changes. + */ +export function appBaseUrl(): string { + return env.BETTER_AUTH_URL + || (env.RAILWAY_PUBLIC_DOMAIN ? `https://${env.RAILWAY_PUBLIC_DOMAIN}` : '') + || 'https://reqcore.com' +} + +export function applicationUrl(applicationId: string): string { + return `${appBaseUrl()}/dashboard/applications/${applicationId}` +} + +export function dashboardUrl(): string { + return `${appBaseUrl()}/dashboard` +} + +export function inboxConversationUrl(conversationId: string): string { + const url = new URL('/dashboard/inbox', appBaseUrl()) + url.searchParams.set('conversation', conversationId) + return url.toString() +} diff --git a/shared/feature-flags.ts b/shared/feature-flags.ts index 11472957..f9b19aeb 100644 --- a/shared/feature-flags.ts +++ b/shared/feature-flags.ts @@ -53,6 +53,16 @@ export const FEATURE_FLAGS = { defaultValue: false, description: 'New AI chatbot experience in the dashboard.', }, + /** + * Recruiter notification engine (outbox + digests). On by default everywhere, + * including self-host — it's a core activation driver. Kept as a flag for + * gradual cloud rollout and a fast client-visible kill-switch; the + * NOTIFICATIONS_ENABLED env var is the server-side worker kill-switch. + */ + 'notifications': { + defaultValue: true, + description: 'Recruiter email notifications (new applicant, reply, AI scoring) + daily digest.', + }, } as const satisfies Record export type FeatureFlagKey = keyof typeof FEATURE_FLAGS diff --git a/shared/notifications.ts b/shared/notifications.ts new file mode 100644 index 00000000..0648adbd --- /dev/null +++ b/shared/notifications.ts @@ -0,0 +1,53 @@ +/** + * Shared notification constants — single source of truth for the recruiter + * notification engine's event types, delivery modes, and per-type defaults. + * + * Imported by both the server (recipient resolution, dispatch) and the client + * (preferences UI) so the two never drift. + */ + +/** The three v1 recruiter-facing events. */ +export const NOTIFICATION_TYPES = [ + 'candidate_replied', + 'application_created', + 'analysis_completed', +] as const + +export type NotificationType = (typeof NOTIFICATION_TYPES)[number] + +/** How a recipient wants a given event delivered. `off` disables it entirely. */ +export type NotificationChannelMode = 'instant' | 'digest' | 'off' + +/** Delivery cadence of an individual outbox row (never `off` — off rows are not enqueued). */ +export type NotificationCadence = 'instant' | 'digest' + +/** + * Default delivery mode when a recipient has no explicit preference row. + * New-applicant is batched by default (high volume); replies and finished + * scoring are instant because they're the pull-back-in moments. + */ +export const DEFAULT_CHANNEL_MODE: Record = { + candidate_replied: 'instant', + application_created: 'digest', + analysis_completed: 'instant', +} + +/** Human-readable copy for the preferences UI. */ +export const NOTIFICATION_TYPE_META: Record = { + candidate_replied: { + label: 'Candidate replied', + description: 'A candidate answered one of your messages.', + }, + application_created: { + label: 'New applicant', + description: 'Someone applied to one of your open roles.', + }, + analysis_completed: { + label: 'AI scoring finished', + description: 'An applicant was scored and is ready to review.', + }, +} + +export function isNotificationType(value: string): value is NotificationType { + return (NOTIFICATION_TYPES as readonly string[]).includes(value) +} diff --git a/tests/unit/notification-delivery-status.test.ts b/tests/unit/notification-delivery-status.test.ts new file mode 100644 index 00000000..5298db02 --- /dev/null +++ b/tests/unit/notification-delivery-status.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NotificationStatusEvent } from '../../server/utils/notifications/delivery-status' +import { processNotificationStatusEvent } from '../../server/utils/notifications/delivery-status' + +function statusEvent(type: NotificationStatusEvent['type'], extra: Record = {}) { + return { + type, + created_at: '2026-01-02T09:00:00.000Z', + data: { + email_id: 'provider_1', + ...extra, + }, + } as unknown as NotificationStatusEvent +} + +function makeDb(row: Record | undefined) { + const setCalls: Array> = [] + const inserted: Array> = [] + return { + setCalls, + inserted, + db: { + query: { notificationOutbox: { findFirst: vi.fn().mockResolvedValue(row) } }, + update: () => ({ + set: (values: Record) => { + setCalls.push(values) + return { where: () => Promise.resolve() } + }, + }), + insert: () => ({ + values: (values: Record) => { + inserted.push(values) + return { onConflictDoNothing: () => Promise.resolve() } + }, + }), + }, + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('processNotificationStatusEvent', () => { + const instantRow = { + id: 'outbox_1', + organizationId: 'org1', + recipientUserId: 'u1', + recipientEmail: 'Recruiter@Example.COM ', + cadence: 'instant', + digestBucket: null, + } + + it('marks a permanent bounce dead and suppresses the normalized address', async () => { + const { db, setCalls, inserted } = makeDb(instantRow) + vi.stubGlobal('db', db) + + await processNotificationStatusEvent(statusEvent('email.bounced', { + bounce: { type: 'Permanent', message: 'Mailbox does not exist' }, + })) + + expect(setCalls[0]).toMatchObject({ status: 'dead', lastError: 'Permanent: Mailbox does not exist' }) + expect(inserted[0]).toEqual({ email: 'recruiter@example.com', reason: 'bounce' }) + }) + + it('marks a soft bounce dead without permanently suppressing the address', async () => { + const { db, setCalls, inserted } = makeDb(instantRow) + vi.stubGlobal('db', db) + + await processNotificationStatusEvent(statusEvent('email.bounced', { + bounce: { type: 'Transient', message: 'Try again later' }, + })) + + expect(setCalls[0]).toMatchObject({ status: 'dead' }) + expect(inserted).toHaveLength(0) + }) + + it('keeps a delivered outbox row sent', async () => { + const { db, setCalls } = makeDb(instantRow) + vi.stubGlobal('db', db) + + await processNotificationStatusEvent(statusEvent('email.delivered')) + + expect(setCalls[0]!.status).toBeUndefined() + expect(setCalls[0]!.failedAt).toBeUndefined() + }) + + it('updates a digest delivery through its leader row', async () => { + const { db, setCalls } = makeDb({ ...instantRow, cadence: 'digest', digestBucket: '2026-01-02' }) + vi.stubGlobal('db', db) + + await processNotificationStatusEvent(statusEvent('email.failed', { + failed: { reason: 'rejected' }, + })) + + expect(setCalls[0]).toMatchObject({ status: 'dead', lastError: 'failed: rejected' }) + }) +}) diff --git a/tests/unit/notification-dispatch.test.ts b/tests/unit/notification-dispatch.test.ts new file mode 100644 index 00000000..864a84e8 --- /dev/null +++ b/tests/unit/notification-dispatch.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sendMock, featureMock } = vi.hoisted(() => ({ + sendMock: vi.fn(), + featureMock: vi.fn(), +})) +vi.mock('../../server/utils/email', () => ({ sendNotificationEmail: sendMock })) +vi.mock('../../server/utils/featureFlags', () => ({ isServerFeatureEnabled: featureMock })) + +const { runNotificationDispatch, runNotificationDigest } = await import('../../server/utils/notifications/dispatch') + +function queryable(result: unknown) { + const p = Promise.resolve(result) + const proxy: unknown = new Proxy(() => {}, { + get(_t, prop) { + if (prop === 'then') return p.then.bind(p) + if (prop === 'catch') return p.catch.bind(p) + if (prop === 'finally') return p.finally.bind(p) + return () => proxy + }, + }) + return proxy +} + +function makeDb(selectResults: unknown[][], returningResults: unknown[][] = []) { + const setCalls: Array> = [] + let selectIndex = 0 + let returningIndex = 0 + + const database: Record = { + select: () => queryable(selectResults[selectIndex++] ?? []), + update: () => ({ + set: (vals: Record) => { + setCalls.push(vals) + return { + where: () => { + const promise = Promise.resolve() + return Object.assign(promise, { + returning: () => Promise.resolve(returningResults[returningIndex++] ?? []), + }) + }, + } + }, + }), + } + database.transaction = async (callback: (tx: unknown) => unknown) => callback(database) + return { db: database, setCalls } +} + +function pendingRow(overrides: Record = {}) { + return { + id: 'outbox_1', + organizationId: 'org1', + recipientUserId: 'u1', + recipientEmail: 'rec@example.com', + type: 'application_created', + cadence: 'instant', + payload: { applicationId: 'app1', candidateName: 'Jane', jobTitle: 'Engineer' }, + status: 'pending', + attempts: 0, + nextAttemptAt: new Date('2026-01-01T00:00:00Z'), + digestBucket: null, + providerMessageId: null, + sentAt: null, + failedAt: null, + lastError: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + } +} + +beforeEach(() => { + vi.stubGlobal('logInfo', vi.fn()) + vi.stubGlobal('logWarn', vi.fn()) + vi.stubGlobal('logError', vi.fn()) + featureMock.mockResolvedValue(true) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + sendMock.mockReset() + featureMock.mockReset() +}) + +describe('runNotificationDispatch', () => { + it('does nothing when the global kill-switch is off', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: false }) + const select = vi.fn() + vi.stubGlobal('db', { select }) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.enabled).toBe(false) + expect(select).not.toHaveBeenCalled() + }) + + it('claims and marks an instant row sent with its provider message id', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true, BETTER_AUTH_URL: 'https://app.example.com' }) + const row = pendingRow() + const { db, setCalls } = makeDb([[{ id: row.id }], [], []], [[row]]) + vi.stubGlobal('db', db) + sendMock.mockResolvedValue({ providerMessageId: 'prov_1' }) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.sent).toBe(1) + expect(setCalls[0]!.nextAttemptAt).toBeInstanceOf(Date) + expect(setCalls[1]).toMatchObject({ status: 'sent', providerMessageId: 'prov_1' }) + }) + + it('does not send when another worker wins the claim', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true }) + const { db } = makeDb([[{ id: 'outbox_1' }], []], [[]]) + vi.stubGlobal('db', db) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.processed).toBe(0) + expect(sendMock).not.toHaveBeenCalled() + }) + + it('schedules the first failed attempt one minute later', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T12:00:00Z')) + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true, BETTER_AUTH_URL: 'https://app.example.com' }) + const row = pendingRow() + const { db, setCalls } = makeDb([[{ id: row.id }], [], []], [[row]]) + vi.stubGlobal('db', db) + sendMock.mockRejectedValue(new Error('resend down')) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.retried).toBe(1) + expect(setCalls[1]).toMatchObject({ attempts: 1 }) + expect((setCalls[1]!.nextAttemptAt as Date).getTime()).toBe(Date.now() + 60_000) + }) + + it('dead-letters a row on its sixth failed attempt', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true, BETTER_AUTH_URL: 'https://app.example.com' }) + const row = pendingRow({ attempts: 5 }) + const { db, setCalls } = makeDb([[{ id: row.id }], [], []], [[row]]) + vi.stubGlobal('db', db) + sendMock.mockRejectedValue(new Error('resend down')) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.dead).toBe(1) + expect(setCalls[1]).toMatchObject({ status: 'dead', attempts: 6 }) + }) + + it('skips a claimed row whose address became suppressed', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true }) + const row = pendingRow() + const { db, setCalls } = makeDb([[{ id: row.id }], [{ id: 'suppression_1' }], []], [[row]]) + vi.stubGlobal('db', db) + + const result = await runNotificationDispatch({ source: 'interactive' }) + + expect(result.skipped).toBe(1) + expect(setCalls[1]).toMatchObject({ status: 'skipped', lastError: 'email_suppressed' }) + expect(sendMock).not.toHaveBeenCalled() + }) +}) + +describe('runNotificationDigest', () => { + it('isolates a digest group and uses a stable organization/user/bucket key', async () => { + vi.stubGlobal('env', { NOTIFICATIONS_ENABLED: true, BETTER_AUTH_URL: 'https://app.example.com' }) + const row = pendingRow({ + cadence: 'digest', + digestBucket: '2026-01-02', + }) + const group = { + organizationId: row.organizationId, + recipientUserId: row.recipientUserId, + recipientEmail: row.recipientEmail, + digestBucket: row.digestBucket, + } + const { db, setCalls } = makeDb([[group], []], [[row]]) + vi.stubGlobal('db', db) + sendMock.mockResolvedValue({ providerMessageId: 'digest_provider' }) + + const result = await runNotificationDigest({ source: 'interactive' }) + + expect(result.sent).toBe(1) + expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: 'digest:org1:u1:2026-01-02', + })) + expect(setCalls).toEqual(expect.arrayContaining([ + expect.objectContaining({ status: 'sent', providerMessageId: null }), + expect.objectContaining({ providerMessageId: 'digest_provider' }), + ])) + }) +}) diff --git a/tests/unit/notification-enqueue.test.ts b/tests/unit/notification-enqueue.test.ts new file mode 100644 index 00000000..740ab3c0 --- /dev/null +++ b/tests/unit/notification-enqueue.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { recipientsMock } = vi.hoisted(() => ({ recipientsMock: vi.fn() })) +vi.mock('../../server/utils/notifications/recipients', () => ({ + resolveRecipients: recipientsMock, +})) + +const { digestBucketFor, digestDeliveryAt, enqueueNotification } = await import('../../server/utils/notifications/enqueue') + +function insertionDb() { + const stored = new Map>() + return { + stored, + db: { + insert: () => ({ + values: (rows: Array>) => ({ + onConflictDoNothing: async () => { + for (const row of rows) { + const key = String(row.dedupeKey) + if (!stored.has(key)) stored.set(key, row) + } + }, + }), + }), + }, + } +} + +beforeEach(() => { + vi.stubGlobal('logWarn', vi.fn()) + recipientsMock.mockResolvedValue([ + { userId: 'u1', email: 'one@example.com', cadence: 'instant' }, + { userId: 'u2', email: 'two@example.com', cadence: 'digest' }, + ]) +}) + +afterEach(() => { + vi.unstubAllGlobals() + recipientsMock.mockReset() +}) + +describe('enqueueNotification', () => { + it('deduplicates each recipient and schedules digest delivery at 08:00 UTC', async () => { + const { db, stored } = insertionDb() + vi.stubGlobal('db', db) + const event = { + organizationId: 'org1', + type: 'application_created' as const, + dedupeKey: 'application_created:app1', + payload: { applicationId: 'app1' }, + } + + await enqueueNotification(event) + await enqueueNotification(event) + + expect(stored.size).toBe(2) + expect([...stored.keys()]).toEqual([ + 'application_created:app1:u1', + 'application_created:app1:u2', + ]) + const digest = stored.get('application_created:app1:u2')! + expect(digest.digestBucket).toBeTypeOf('string') + expect((digest.nextAttemptAt as Date).toISOString()).toBe(`${digest.digestBucket}T08:00:00.000Z`) + }) + + it('propagates failures when participating in a domain transaction', async () => { + recipientsMock.mockRejectedValue(new Error('database unavailable')) + const tx = {} as never + + await expect(enqueueNotification({ + organizationId: 'org1', + type: 'candidate_replied', + dedupeKey: 'candidate_replied:m1', + payload: {}, + tx, + })).rejects.toThrow('database unavailable') + }) + + it('logs and swallows failures outside a transaction', async () => { + recipientsMock.mockRejectedValue(new Error('database unavailable')) + vi.stubGlobal('db', {}) + + await expect(enqueueNotification({ + organizationId: 'org1', + type: 'candidate_replied', + dedupeKey: 'candidate_replied:m1', + payload: {}, + })).resolves.toBeUndefined() + expect(logWarn).toHaveBeenCalledWith('notification.enqueue_failed', expect.any(Object)) + }) +}) + +describe('digest bucketing', () => { + it('uses today before 08:00 UTC and tomorrow at or after the cutoff', () => { + expect(digestBucketFor(new Date('2026-01-02T07:59:59Z'))).toBe('2026-01-02') + expect(digestBucketFor(new Date('2026-01-02T08:00:00Z'))).toBe('2026-01-03') + expect(digestDeliveryAt('2026-01-03').toISOString()).toBe('2026-01-03T08:00:00.000Z') + }) +}) diff --git a/tests/unit/notification-recipients.test.ts b/tests/unit/notification-recipients.test.ts new file mode 100644 index 00000000..c2a7f7ee --- /dev/null +++ b/tests/unit/notification-recipients.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { resolveRecipients, type NotificationDbClient } from '../../server/utils/notifications/recipients' + +/** + * Minimal fake Drizzle client whose `.select()` chains resolve to a queued list + * of results, in call order. resolveRecipients runs exactly three selects: + * members, preferences, then suppressed addresses. + */ +function fakeDb(resultsInOrder: unknown[]): NotificationDbClient { + let i = 0 + function makeChain() { + const current = resultsInOrder[i] + const p = Promise.resolve(current) + const proxy: unknown = new Proxy(() => {}, { + get(_t, prop) { + if (prop === 'then') { i++; return p.then.bind(p) } + if (prop === 'catch') return p.catch.bind(p) + if (prop === 'finally') return p.finally.bind(p) + return () => proxy + }, + }) + return proxy + } + return { select: () => makeChain() } as unknown as NotificationDbClient +} + +describe('resolveRecipients', () => { + it('applies defaults, honors off/instant, and excludes suppressed addresses', async () => { + const members = [ + { userId: 'a', email: 'a@example.com' }, // no pref → default (digest for application_created) + { userId: 'b', email: 'b@example.com' }, // pref: off → excluded + { userId: 'c', email: 'c@example.com' }, // pref: instant + { userId: 'd', email: 'd@example.com' }, // suppressed → excluded + ] + const prefs = [ + { userId: 'b', channelMode: 'off' }, + { userId: 'c', channelMode: 'instant' }, + ] + const suppressed = [{ email: 'd@example.com' }] + + const recipients = await resolveRecipients( + 'org1', + 'application_created', + fakeDb([members, prefs, suppressed]), + ) + + expect(recipients).toEqual([ + { userId: 'a', email: 'a@example.com', cadence: 'digest' }, + { userId: 'c', email: 'c@example.com', cadence: 'instant' }, + ]) + }) + + it('returns no recipients when the org has no members', async () => { + const recipients = await resolveRecipients('org1', 'candidate_replied', fakeDb([[]])) + expect(recipients).toEqual([]) + }) +}) diff --git a/tests/unit/notification-templates.test.ts b/tests/unit/notification-templates.test.ts new file mode 100644 index 00000000..0d941d4b --- /dev/null +++ b/tests/unit/notification-templates.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { renderNotification, renderDigest } from '../../server/utils/notifications/templates' + +describe('notification templates', () => { + it('renders an application_created email with subject, deep link, and details', () => { + const rendered = renderNotification('application_created', { + candidateName: 'Jane Doe', + jobTitle: 'Staff Engineer', + applicationUrl: 'https://app.example.com/dashboard/applications/abc', + }) + expect(rendered).not.toBeNull() + expect(rendered!.subject).toBe('New applicant for Staff Engineer') + expect(rendered!.html).toContain('Jane Doe') + expect(rendered!.html).toContain('https://app.example.com/dashboard/applications/abc') + expect(rendered!.text).toContain('Jane Doe applied for Staff Engineer') + }) + + it('escapes untrusted candidate and job strings in the HTML', () => { + const rendered = renderNotification('application_created', { + candidateName: '', + jobTitle: 'Dev ">', + applicationUrl: 'https://app.example.com/dashboard/applications/abc', + }) + expect(rendered!.html).not.toContain('') + expect(rendered!.html).toContain('<script>alert(1)</script>') + expect(rendered!.html).not.toContain('') + }) + + it('includes the composite score when present and omits it when null', () => { + const withScore = renderNotification('analysis_completed', { + candidateName: 'Amir', + jobTitle: 'PM', + compositeScore: 87, + applicationUrl: 'https://app.example.com/dashboard/applications/x', + }) + expect(withScore!.html).toContain('87/100') + + const withoutScore = renderNotification('analysis_completed', { + candidateName: 'Amir', + jobTitle: 'PM', + compositeScore: null, + applicationUrl: 'https://app.example.com/dashboard/applications/x', + }) + expect(withoutScore!.html).not.toContain('/100') + }) + + it('rolls several events into one digest email', () => { + const digest = renderDigest({ + items: [ + { type: 'application_created', payload: { candidateName: 'A', jobTitle: 'Role 1' }, applicationUrl: 'https://x/1' }, + { type: 'candidate_replied', payload: { candidateName: 'B', jobTitle: 'Role 2', preview: 'Thanks' }, applicationUrl: 'https://x/2' }, + ], + dashboardUrl: 'https://x/dashboard', + }) + expect(digest!.subject).toContain('2 updates') + expect(digest!.html).toContain('Role 1') + expect(digest!.html).toContain('Role 2') + expect(digest!.html).toContain('https://x/dashboard') + }) + + it('rejects malformed instant and digest payloads', () => { + expect(renderNotification('application_created', { + candidateName: 'Jane', + applicationUrl: 'https://x/app', + })).toBeNull() + expect(renderDigest({ + items: [{ + type: 'candidate_replied', + payload: { candidateName: 'Jane', jobTitle: 'Engineer' }, + applicationUrl: 'https://x/app', + }], + dashboardUrl: 'https://x/dashboard', + })).toBeNull() + }) +}) From 3f8f72dd3e22ef1ec6d215ac03ee5a3f2d2e5fd3 Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 09:55:48 +0200 Subject: [PATCH 02/12] Disable email notifications for demo account Implement logic to force notification preferences to "off" for the demo account, preventing unwanted emails during demos. Updates include enforcing this in API handlers, notification resolution, and the seeding script. --- railway.json | 4 +- .../api/notification-preferences/index.get.ts | 5 +++ .../notification-preferences/index.patch.ts | 5 +++ server/scripts/seed.ts | 40 +++++++++++++++++++ server/utils/notifications/recipients.ts | 2 + tests/unit/demo-account-isolation.test.ts | 10 +++++ tests/unit/notification-recipients.test.ts | 17 ++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/railway.json b/railway.json index d63bcbbd..0a2226c1 100644 --- a/railway.json +++ b/railway.json @@ -1,7 +1,7 @@ { - "$schema": "https://railway.app/railway.schema.json", + "$schema": "https://railway.com/railway.schema.json", "deploy": { - "preDeployCommand": "npm run db:migrate && npm run db:seed", + "preDeployCommand": "/bin/sh -c \"npm run db:migrate && npm run db:seed\"", "startCommand": "npm run start" } } diff --git a/server/api/notification-preferences/index.get.ts b/server/api/notification-preferences/index.get.ts index 399e3201..1ea420db 100644 --- a/server/api/notification-preferences/index.get.ts +++ b/server/api/notification-preferences/index.get.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm' import { notificationPreference } from '../../database/schema' import { DEFAULT_CHANNEL_MODE, NOTIFICATION_TYPES } from '~~/shared/notifications' +import { isDemoAccountEmail } from '../../utils/demoOrg' /** * GET /api/notification-preferences @@ -17,6 +18,10 @@ export default defineEventHandler(async (event) => { }) if (!enabled) throw createError({ statusCode: 404, statusMessage: 'Not found' }) + if (isDemoAccountEmail(session.user.email)) { + return NOTIFICATION_TYPES.map(type => ({ type, channelMode: 'off' as const })) + } + const rows = await db.select({ type: notificationPreference.type, channelMode: notificationPreference.channelMode, diff --git a/server/api/notification-preferences/index.patch.ts b/server/api/notification-preferences/index.patch.ts index 46211cb3..2f9cbf46 100644 --- a/server/api/notification-preferences/index.patch.ts +++ b/server/api/notification-preferences/index.patch.ts @@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm' import { z } from 'zod' import { notificationPreference } from '../../database/schema' import { DEFAULT_CHANNEL_MODE, NOTIFICATION_TYPES } from '~~/shared/notifications' +import { isDemoAccountEmail } from '../../utils/demoOrg' const bodySchema = z.object({ preferences: z.array(z.object({ @@ -25,6 +26,10 @@ export default defineEventHandler(async (event) => { }) if (!enabled) throw createError({ statusCode: 404, statusMessage: 'Not found' }) + if (isDemoAccountEmail(session.user.email)) { + return NOTIFICATION_TYPES.map(type => ({ type, channelMode: 'off' as const })) + } + const { preferences } = await readValidatedBody(event, bodySchema.parse) for (const pref of preferences) { diff --git a/server/scripts/seed.ts b/server/scripts/seed.ts index 93d51c34..a80e385f 100644 --- a/server/scripts/seed.ts +++ b/server/scripts/seed.ts @@ -57,6 +57,11 @@ const DEMO_EMAIL = "demo@reqcore.com"; const DEMO_PASSWORD = process.env.DEMO_PASSWORD ?? "demo1234"; const DEMO_ORG_NAME = "Reqcore Demo"; const DEMO_ORG_SLUG = "reqcore-demo"; +const DEMO_NOTIFICATION_TYPES = [ + "candidate_replied", + "application_created", + "analysis_completed", +] as const; // Legacy values from the old applirank.com domain — cleaned up on seed const LEGACY_DEMO_EMAIL = "demo@applirank.com"; @@ -120,6 +125,37 @@ function generateSlug(title: string, uuid: string): string { return `${base}-${shortId}`; } +async function disableDemoEmailNotifications( + userId: string, + organizationId: string, +): Promise { + const now = new Date(); + + for (const type of DEMO_NOTIFICATION_TYPES) { + await db + .insert(schema.notificationPreference) + .values({ + id: id(), + userId, + organizationId, + type, + channelMode: "off", + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + schema.notificationPreference.userId, + schema.notificationPreference.organizationId, + schema.notificationPreference.type, + ], + set: { channelMode: "off", updatedAt: now }, + }); + } + + console.log("✅ Disabled email notifications for the demo account"); +} + // ───────────────────────────────────────────── // Seed Data Definitions // ───────────────────────────────────────────── @@ -9345,6 +9381,8 @@ async function seed() { console.log("✅ Linked demo user to existing org as owner"); } + await disableDemoEmailNotifications(userId, existingOrg.id); + console.log("⚠️ Demo organization already exists. Skipping full seed."); console.log( " To re-seed all data, delete the organization first or reset the database.", @@ -9372,6 +9410,8 @@ async function seed() { createdAt: daysAgo(30), }); + await disableDemoEmailNotifications(userId, orgId); + console.log(`✅ Created organization: ${DEMO_ORG_NAME}`); // 3. Create jobs diff --git a/server/utils/notifications/recipients.ts b/server/utils/notifications/recipients.ts index a6af98de..be294e52 100644 --- a/server/utils/notifications/recipients.ts +++ b/server/utils/notifications/recipients.ts @@ -10,6 +10,7 @@ import { type NotificationCadence, type NotificationType, } from '~~/shared/notifications' +import { isDemoAccountEmail } from '../demoOrg' /** Either the global db or an open transaction — both expose the query/select API we use. */ type Tx = Parameters[0]>[0] @@ -56,6 +57,7 @@ export async function resolveRecipients( const recipients: NotificationRecipient[] = [] for (const m of members) { + if (isDemoAccountEmail(m.email)) continue if (suppressedSet.has(m.email.toLowerCase())) continue const mode = prefByUser.get(m.userId) ?? DEFAULT_CHANNEL_MODE[type] if (mode === 'off') continue diff --git a/tests/unit/demo-account-isolation.test.ts b/tests/unit/demo-account-isolation.test.ts index 250c8e7c..7202ae86 100644 --- a/tests/unit/demo-account-isolation.test.ts +++ b/tests/unit/demo-account-isolation.test.ts @@ -27,4 +27,14 @@ describe('demo account organization isolation', () => { expect(read('server/api/invite-links/accept.post.ts')).toMatch(/The demo account cannot join other organizations/) expect(read('server/api/join-requests/index.post.ts')).toMatch(/The demo account cannot request access/) }) + + it('keeps demo account email notification preferences disabled', () => { + const seed = read('server/scripts/seed.ts') + + expect(seed).toMatch(/disableDemoEmailNotifications\(userId, existingOrg\.id\)/) + expect(seed).toMatch(/disableDemoEmailNotifications\(userId, orgId\)/) + expect(seed).toMatch(/set: \{ channelMode: "off", updatedAt: now \}/) + expect(read('server/api/notification-preferences/index.get.ts')).toMatch(/isDemoAccountEmail\(session\.user\.email\)/) + expect(read('server/api/notification-preferences/index.patch.ts')).toMatch(/isDemoAccountEmail\(session\.user\.email\)/) + }) }) diff --git a/tests/unit/notification-recipients.test.ts b/tests/unit/notification-recipients.test.ts index c2a7f7ee..fa5b7eb3 100644 --- a/tests/unit/notification-recipients.test.ts +++ b/tests/unit/notification-recipients.test.ts @@ -54,4 +54,21 @@ describe('resolveRecipients', () => { const recipients = await resolveRecipients('org1', 'candidate_replied', fakeDb([[]])) expect(recipients).toEqual([]) }) + + it('never returns the shared demo account as an email recipient', async () => { + const members = [ + { userId: 'demo', email: 'demo@reqcore.com' }, + { userId: 'recruiter', email: 'recruiter@example.com' }, + ] + + const recipients = await resolveRecipients( + 'org1', + 'candidate_replied', + fakeDb([members, [], []]), + ) + + expect(recipients).toEqual([ + { userId: 'recruiter', email: 'recruiter@example.com', cadence: 'instant' }, + ]) + }) }) From 58e0ffa37d1ed1ac4ac4868e8459ac4ea26cf792 Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 10:05:09 +0200 Subject: [PATCH 03/12] Add guard clause to migration 0048 Prevents the migration from running if the schema changes already exist in the Drizzle ledger from migrations 0045 and 0046. --- .../migrations/0048_brave_human_torch.sql | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/server/database/migrations/0048_brave_human_torch.sql b/server/database/migrations/0048_brave_human_torch.sql index 54396629..2930f140 100644 --- a/server/database/migrations/0048_brave_human_torch.sql +++ b/server/database/migrations/0048_brave_human_torch.sql @@ -1,15 +1,26 @@ -CREATE TYPE "public"."email_suppression_reason" AS ENUM('bounce', 'complaint');--> statement-breakpoint -CREATE TYPE "public"."notification_cadence" AS ENUM('instant', 'digest');--> statement-breakpoint -CREATE TYPE "public"."notification_channel_mode" AS ENUM('instant', 'digest', 'off');--> statement-breakpoint -CREATE TYPE "public"."notification_outbox_status" AS ENUM('pending', 'sent', 'skipped', 'dead');--> statement-breakpoint -CREATE TYPE "public"."notification_type" AS ENUM('candidate_replied', 'application_created', 'analysis_completed');--> statement-breakpoint +DO $migration$ +BEGIN + -- This PR previously deployed the same schema as migrations 0045 and 0046. + -- Skip the renumbered migration when both legacy entries are in Drizzle's ledger. + IF EXISTS ( + SELECT 1 FROM "drizzle"."__drizzle_migrations" WHERE "created_at" = 1784529680195 + ) AND EXISTS ( + SELECT 1 FROM "drizzle"."__drizzle_migrations" WHERE "created_at" = 1784532824839 + ) THEN + RETURN; + END IF; + +CREATE TYPE "public"."email_suppression_reason" AS ENUM('bounce', 'complaint'); +CREATE TYPE "public"."notification_cadence" AS ENUM('instant', 'digest'); +CREATE TYPE "public"."notification_channel_mode" AS ENUM('instant', 'digest', 'off'); +CREATE TYPE "public"."notification_outbox_status" AS ENUM('pending', 'sent', 'skipped', 'dead'); +CREATE TYPE "public"."notification_type" AS ENUM('candidate_replied', 'application_created', 'analysis_completed'); CREATE TABLE "email_suppression" ( "id" text PRIMARY KEY NOT NULL, "email" text NOT NULL, "reason" "email_suppression_reason" NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL ); ---> statement-breakpoint CREATE TABLE "notification_outbox" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, @@ -30,7 +41,6 @@ CREATE TABLE "notification_outbox" ( "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL ); ---> statement-breakpoint CREATE TABLE "notification_preference" ( "id" text PRIMARY KEY NOT NULL, "user_id" text NOT NULL, @@ -40,16 +50,17 @@ CREATE TABLE "notification_preference" ( "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL ); ---> statement-breakpoint -ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "email_suppression_email_idx" ON "email_suppression" USING btree (lower("email"));--> statement-breakpoint -CREATE UNIQUE INDEX "notification_outbox_dedupe_key_idx" ON "notification_outbox" USING btree ("dedupe_key");--> statement-breakpoint -CREATE INDEX "notification_outbox_pull_idx" ON "notification_outbox" USING btree ("cadence","next_attempt_at") WHERE "notification_outbox"."status" = 'pending';--> statement-breakpoint -CREATE UNIQUE INDEX "notification_outbox_provider_id_idx" ON "notification_outbox" USING btree ("provider_message_id") WHERE "notification_outbox"."provider_message_id" is not null;--> statement-breakpoint -CREATE INDEX "notification_outbox_organization_id_idx" ON "notification_outbox" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "notification_outbox_digest_idx" ON "notification_outbox" USING btree ("organization_id","recipient_user_id","digest_bucket");--> statement-breakpoint -CREATE UNIQUE INDEX "notification_preference_user_org_type_idx" ON "notification_preference" USING btree ("user_id","organization_id","type");--> statement-breakpoint -CREATE INDEX "notification_preference_organization_id_idx" ON "notification_preference" USING btree ("organization_id"); \ No newline at end of file +ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "notification_outbox" ADD CONSTRAINT "notification_outbox_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; +CREATE UNIQUE INDEX "email_suppression_email_idx" ON "email_suppression" USING btree (lower("email")); +CREATE UNIQUE INDEX "notification_outbox_dedupe_key_idx" ON "notification_outbox" USING btree ("dedupe_key"); +CREATE INDEX "notification_outbox_pull_idx" ON "notification_outbox" USING btree ("cadence","next_attempt_at") WHERE "notification_outbox"."status" = 'pending'; +CREATE UNIQUE INDEX "notification_outbox_provider_id_idx" ON "notification_outbox" USING btree ("provider_message_id") WHERE "notification_outbox"."provider_message_id" is not null; +CREATE INDEX "notification_outbox_organization_id_idx" ON "notification_outbox" USING btree ("organization_id"); +CREATE INDEX "notification_outbox_digest_idx" ON "notification_outbox" USING btree ("organization_id","recipient_user_id","digest_bucket"); +CREATE UNIQUE INDEX "notification_preference_user_org_type_idx" ON "notification_preference" USING btree ("user_id","organization_id","type"); +CREATE INDEX "notification_preference_organization_id_idx" ON "notification_preference" USING btree ("organization_id"); +END +$migration$; From dbf53c71a6f8805cb205b01ed9b428c32201bc60 Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 13:56:51 +0200 Subject: [PATCH 04/12] Replace analysis notifications with interview responses - Add `interview_response` notification type - Remove `analysis_completed` notification type and associated outbox entries - Implement notification for interview status changes (accepted/declined/tentative) - Add database migration to update `notification_type` enum and cleanup legacy data --- .env.example | 2 +- app/composables/useNotificationPreferences.ts | 4 +- .../dashboard/settings/notifications.vue | 81 +- server/api/applications/[id]/analyze.post.ts | 21 +- .../api/notification-preferences/index.get.ts | 8 +- .../notification-preferences/index.patch.ts | 19 +- server/api/public/interviews/respond.post.ts | 18 +- .../migrations/0049_friendly_blacklash.sql | 1 + .../migrations/0050_numerous_red_wolf.sql | 14 + .../migrations/meta/0049_snapshot.json | 7850 +++++++++++++++++ .../migrations/meta/0050_snapshot.json | 7849 ++++++++++++++++ server/database/migrations/meta/_journal.json | 14 + server/database/schema/app.ts | 8 +- server/scripts/seed.ts | 2 +- server/utils/ai/autoScore.ts | 21 +- server/utils/notifications/dispatch.ts | 12 +- server/utils/notifications/enqueue.ts | 4 +- .../utils/notifications/interview-response.ts | 29 + server/utils/notifications/recipients.ts | 3 +- server/utils/notifications/templates.ts | 102 +- server/utils/notifications/urls.ts | 4 + shared/feature-flags.ts | 2 +- shared/notifications.ts | 33 +- tests/unit/notification-dispatch.test.ts | 25 + tests/unit/notification-enqueue.test.ts | 29 + tests/unit/notification-recipients.test.ts | 18 +- tests/unit/notification-templates.test.ts | 42 +- 27 files changed, 16051 insertions(+), 164 deletions(-) create mode 100644 server/database/migrations/0049_friendly_blacklash.sql create mode 100644 server/database/migrations/0050_numerous_red_wolf.sql create mode 100644 server/database/migrations/meta/0049_snapshot.json create mode 100644 server/database/migrations/meta/0050_snapshot.json create mode 100644 server/utils/notifications/interview-response.ts diff --git a/.env.example b/.env.example index e7e4dc2f..66c9f2f3 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,7 @@ GDPR_CLEANUP_ENABLED=false CRON_SECRET= # ─── Recruiter notifications ──────────────────────────────────────────────── -# Email notifications to recruiters (new applicant, candidate reply, AI scoring +# Email notifications to recruiters (new applicant, candidate reply, interview response # finished) via a durable outbox drained by a scheduled worker. Fail-safe # default = true (activation driver, safe to send). Set to false to pause all # notification sending + digests instance-wide. diff --git a/app/composables/useNotificationPreferences.ts b/app/composables/useNotificationPreferences.ts index 6563a2ab..9e5c956b 100644 --- a/app/composables/useNotificationPreferences.ts +++ b/app/composables/useNotificationPreferences.ts @@ -9,10 +9,10 @@ export interface NotificationPreference { * Composable for reading and updating the current user's recruiter notification * preferences for the active organization. */ -export function useNotificationPreferences() { +export async function useNotificationPreferences() { const { handlePreviewReadOnlyError } = usePreviewReadOnly() - const { data: preferences, status, error, refresh } = useFetch( + const { data: preferences, status, error, refresh } = await useFetch( '/api/notification-preferences', { key: 'notification-preferences', diff --git a/app/pages/dashboard/settings/notifications.vue b/app/pages/dashboard/settings/notifications.vue index 3772b2cf..fb96104b 100644 --- a/app/pages/dashboard/settings/notifications.vue +++ b/app/pages/dashboard/settings/notifications.vue @@ -1,6 +1,8 @@ diff --git a/e2e/critical-flows/application-deletion.spec.ts b/e2e/critical-flows/application-deletion.spec.ts new file mode 100644 index 00000000..590eeab1 --- /dev/null +++ b/e2e/critical-flows/application-deletion.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '../fixtures' + +async function createJob(page: Parameters[1]>[0]['page']) { + const response = await page.request.post('/api/jobs', { + data: { + title: `Application deletion role ${Date.now()}`, + description: 'Exercises application and candidate deletion behavior.', + location: 'Oslo, Norway', + type: 'full_time', + status: 'open', + phoneRequirement: 'optional', + requireResume: false, + requireCoverLetter: false, + autoScoreOnApply: false, + questions: [], + criteria: [], + }, + }) + expect(response.status()).toBe(201) + return response.json() as Promise<{ id: string }> +} + +async function createCandidate( + page: Parameters[1]>[0]['page'], + label: string, +) { + const response = await page.request.post('/api/candidates', { + data: { + firstName: label, + lastName: 'Deletion Test', + email: `${label.toLowerCase()}-${Date.now()}@example.com`, + }, + }) + expect(response.status()).toBe(201) + return response.json() as Promise<{ id: string, firstName: string, lastName: string }> +} + +async function createApplication( + page: Parameters[1]>[0]['page'], + candidateId: string, + jobId: string, +) { + const response = await page.request.post('/api/applications', { + data: { candidateId, jobId }, + }) + expect(response.status()).toBe(201) + return response.json() as Promise<{ id: string }> +} + +test('applications can be deleted independently and quarantined candidates disappear from pipelines', async ({ authenticatedPage }) => { + const page = authenticatedPage + const job = await createJob(page) + const retainedCandidate = await createCandidate(page, 'Retained') + const deletedApplication = await createApplication(page, retainedCandidate.id, job.id) + + await page.goto('/dashboard/applications') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Retained Deletion Test' }).click() + await expect(page.getByRole('dialog', { name: 'Application detail' })).toBeVisible() + await page.getByRole('button', { name: 'Delete application' }).click() + + const confirmation = page.getByRole('dialog', { name: 'Delete Application' }) + await expect(confirmation).toContainText('The candidate remains in your candidate list.') + + const deleteResponse = page.waitForResponse(response => + response.request().method() === 'DELETE' + && new URL(response.url()).pathname === `/api/applications/${deletedApplication.id}`, + ) + await confirmation.getByRole('button', { name: 'Delete application' }).click() + expect((await deleteResponse).status()).toBe(204) + + await expect(page.getByRole('button', { name: 'Retained Deletion Test' })).toHaveCount(0) + expect((await page.request.get(`/api/candidates/${retainedCandidate.id}`)).status()).toBe(200) + expect((await page.request.get(`/api/applications/${deletedApplication.id}`)).status()).toBe(404) + + const quarantinedCandidate = await createCandidate(page, 'Quarantined') + const hiddenApplication = await createApplication(page, quarantinedCandidate.id, job.id) + expect((await page.request.delete(`/api/candidates/${quarantinedCandidate.id}`)).status()).toBe(200) + + const applicationsResponse = await page.request.get('/api/applications?limit=100') + expect(applicationsResponse.status()).toBe(200) + const applications = await applicationsResponse.json() as { data: Array<{ id: string }> } + expect(applications.data.map(row => row.id)).not.toContain(hiddenApplication.id) + expect((await page.request.get(`/api/applications/${hiddenApplication.id}`)).status()).toBe(404) +}) diff --git a/ee/server/api/source-tracking/stats.get.ts b/ee/server/api/source-tracking/stats.get.ts index c74ebbb6..477c4f6e 100644 --- a/ee/server/api/source-tracking/stats.get.ts +++ b/ee/server/api/source-tracking/stats.get.ts @@ -1,4 +1,4 @@ -import { eq, and, sql, count, gte, lte, desc } from 'drizzle-orm' +import { eq, and, sql, count, gte, isNull, lte, desc } from 'drizzle-orm' import { applicationSource, application, trackingLink, job, candidate } from '~~/server/database/schema' import { sourceStatsQuerySchema } from '~~/server/utils/schemas/trackingLink' @@ -22,7 +22,10 @@ export default defineEventHandler(async (event) => { const query = await getValidatedQuery(event, sourceStatsQuerySchema.parse) // Build date range conditions - const dateConditions = [eq(applicationSource.organizationId, orgId)] + const dateConditions = [ + eq(applicationSource.organizationId, orgId), + isNull(candidate.quarantinedAt), + ] if (query.jobId) { dateConditions.push(eq(application.jobId, query.jobId)) } @@ -56,6 +59,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(whereClause) .groupBy(applicationSource.channel) .orderBy(sql`count(*) desc`), @@ -69,13 +73,20 @@ export default defineEventHandler(async (event) => { code: trackingLink.code, jobTitle: job.title, clickCount: trackingLink.clickCount, - applicationCount: trackingLink.applicationCount, + applicationCount: count(candidate.id).as('application_count'), isActive: trackingLink.isActive, }) .from(trackingLink) .leftJoin(job, eq(job.id, trackingLink.jobId)) + .leftJoin(applicationSource, eq(applicationSource.trackingLinkId, trackingLink.id)) + .leftJoin(application, eq(application.id, applicationSource.applicationId)) + .leftJoin(candidate, and( + eq(candidate.id, application.candidateId), + isNull(candidate.quarantinedAt), + )) .where(eq(trackingLink.organizationId, orgId)) - .orderBy(desc(trackingLink.applicationCount)) + .groupBy(trackingLink.id, job.title) + .orderBy(desc(count(candidate.id))) .limit(10), // 3. Conversion funnel — application status breakdown per channel @@ -87,6 +98,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(whereClause) .groupBy(applicationSource.channel, application.status), @@ -99,6 +111,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(and( ...dateConditions, gte(applicationSource.createdAt, sql`now() - interval '30 days'`), @@ -133,12 +146,24 @@ export default defineEventHandler(async (event) => { .limit(200), // 6. Total tracked applications (have a source) - db.$count(applicationSource, eq(applicationSource.organizationId, orgId)), + db + .select({ count: count() }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .where(and( + eq(applicationSource.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) + .then(rows => Number(rows[0]?.count ?? 0)), // 7. Total untracked applications (no source record) db.execute(sql` SELECT count(*) as count FROM ${application} a + INNER JOIN ${candidate} c + ON c.id = a.candidate_id + AND c.quarantined_at IS NULL WHERE a.organization_id = ${orgId} AND NOT EXISTS ( SELECT 1 FROM ${applicationSource} s @@ -154,6 +179,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(and( ...dateConditions, sql`${applicationSource.referrerDomain} IS NOT NULL`, diff --git a/server/api/applications/[id].delete.ts b/server/api/applications/[id].delete.ts new file mode 100644 index 00000000..ee8e71e5 --- /dev/null +++ b/server/api/applications/[id].delete.ts @@ -0,0 +1,137 @@ +import { and, eq, inArray } from 'drizzle-orm' +import { + application, + candidateConversation, + candidateMessage, + candidateMessageAttachment, + comment, + interview, + propertyValue, +} from '../../database/schema' +import { applicationIdParamSchema } from '../../utils/schemas/application' +import { cancelCalendarEvent } from '../../utils/google-calendar' + +/** + * DELETE /api/applications/:id + * + * Permanently removes an application and its application-specific data. The + * candidate record and candidate documents are intentionally left untouched. + */ +export default defineEventHandler(async (event) => { + const session = await requirePermission(event, { application: ['delete'] }) + const orgId = session.session.activeOrganizationId + const { id } = await getValidatedRouterParams(event, applicationIdParamSchema.parse) + + const existing = await db.query.application.findFirst({ + where: and(eq(application.id, id), eq(application.organizationId, orgId)), + columns: { id: true }, + }) + if (!existing) { + throw createError({ statusCode: 404, statusMessage: 'Application not found' }) + } + + const applicationInterviews = await db.query.interview.findMany({ + where: and(eq(interview.applicationId, id), eq(interview.organizationId, orgId)), + columns: { + id: true, + status: true, + invitationSentAt: true, + googleCalendarEventId: true, + createdById: true, + }, + }) + const activeInvitation = applicationInterviews.find(row => + row.invitationSentAt && row.status === 'scheduled', + ) + if (activeInvitation) { + throw createError({ + statusCode: 409, + statusMessage: 'Cancel scheduled interviews before deleting this application', + }) + } + + const conversations = await db.query.candidateConversation.findMany({ + where: and( + eq(candidateConversation.applicationId, id), + eq(candidateConversation.organizationId, orgId), + ), + columns: { id: true }, + }) + const conversationIds = conversations.map(row => row.id) + const messages = conversationIds.length > 0 + ? await db.query.candidateMessage.findMany({ + where: and( + inArray(candidateMessage.conversationId, conversationIds), + eq(candidateMessage.organizationId, orgId), + ), + columns: { id: true }, + }) + : [] + const messageIds = messages.map(row => row.id) + const attachments = messageIds.length > 0 + ? await db.query.candidateMessageAttachment.findMany({ + where: and( + inArray(candidateMessageAttachment.messageId, messageIds), + eq(candidateMessageAttachment.organizationId, orgId), + ), + columns: { storageKey: true }, + }) + : [] + + for (const attachment of attachments) { + try { + await deleteFromS3(attachment.storageKey) + } + catch (error) { + logWarn('application.attachment_delete_failed', { + organization_id: orgId, + application_id: id, + storage_key: attachment.storageKey, + error_message: error instanceof Error ? error.message : String(error), + }) + } + } + + await db.transaction(async (tx) => { + await tx.delete(comment).where(and( + eq(comment.organizationId, orgId), + eq(comment.targetType, 'application'), + eq(comment.targetId, id), + )) + await tx.delete(propertyValue).where(and( + eq(propertyValue.organizationId, orgId), + eq(propertyValue.entityType, 'application'), + eq(propertyValue.entityId, id), + )) + + const deleted = await tx.delete(application) + .where(and(eq(application.id, id), eq(application.organizationId, orgId))) + .returning({ id: application.id }) + if (deleted.length === 0) { + throw createError({ statusCode: 404, statusMessage: 'Application not found' }) + } + }) + + for (const row of applicationInterviews) { + if (!row.googleCalendarEventId) continue + cancelCalendarEvent(row.createdById, row.googleCalendarEventId).catch((error) => { + logError('calendar.cancel_event_on_application_delete_failed', { + organization_id: orgId, + application_id: id, + event_id: row.googleCalendarEventId, + error_message: error instanceof Error ? error.message : String(error), + }) + }) + } + + recordActivity({ + organizationId: orgId, + actorId: session.user.id, + action: 'deleted', + resourceType: 'application', + resourceId: id, + }) + + setResponseStatus(event, 204) + return null +}) diff --git a/server/api/applications/[id].get.ts b/server/api/applications/[id].get.ts index aa5ea2e9..46f002e4 100644 --- a/server/api/applications/[id].get.ts +++ b/server/api/applications/[id].get.ts @@ -1,5 +1,5 @@ -import { eq, and } from 'drizzle-orm' -import { application } from '../../database/schema' +import { eq, and, inArray, isNull } from 'drizzle-orm' +import { application, candidate } from '../../database/schema' import { applicationIdParamSchema } from '../../utils/schemas/application' import { loadPropertyEntriesForEntity } from '../../utils/properties' @@ -12,9 +12,17 @@ export default defineEventHandler(async (event) => { const orgId = session.session.activeOrganizationId const { id } = await getValidatedRouterParams(event, applicationIdParamSchema.parse) + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) const result = await db.query.application.findFirst({ - where: and(eq(application.id, id), eq(application.organizationId, orgId)), + where: and( + eq(application.id, id), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ), with: { candidate: { columns: { id: true, firstName: true, lastName: true, email: true, phone: true }, diff --git a/server/api/applications/[id].patch.ts b/server/api/applications/[id].patch.ts index 37abbf0b..cd3580c6 100644 --- a/server/api/applications/[id].patch.ts +++ b/server/api/applications/[id].patch.ts @@ -1,5 +1,5 @@ -import { eq, and } from 'drizzle-orm' -import { application } from '../../database/schema' +import { eq, and, inArray, isNull } from 'drizzle-orm' +import { application, candidate } from '../../database/schema' import { applicationIdParamSchema, updateApplicationSchema, APPLICATION_STATUS_TRANSITIONS } from '../../utils/schemas/application' /** @@ -12,10 +12,18 @@ export default defineEventHandler(async (event) => { const { id } = await getValidatedRouterParams(event, applicationIdParamSchema.parse) const body = await readValidatedBody(event, updateApplicationSchema.parse) + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) // Fetch current application to validate status transition const current = await db.query.application.findFirst({ - where: and(eq(application.id, id), eq(application.organizationId, orgId)), + where: and( + eq(application.id, id), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ), columns: { id: true, status: true }, }) @@ -36,7 +44,11 @@ export default defineEventHandler(async (event) => { const [updated] = await db.update(application) .set({ ...body, updatedAt: new Date() }) - .where(and(eq(application.id, id), eq(application.organizationId, orgId))) + .where(and( + eq(application.id, id), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + )) .returning({ id: application.id, candidateId: application.candidateId, diff --git a/server/api/applications/[id]/analyze.post.ts b/server/api/applications/[id]/analyze.post.ts index 25850486..09b33425 100644 --- a/server/api/applications/[id]/analyze.post.ts +++ b/server/api/applications/[id]/analyze.post.ts @@ -1,6 +1,6 @@ -import { eq, and } from 'drizzle-orm' +import { eq, and, inArray, isNull } from 'drizzle-orm' import { - application, scoringCriterion, criterionScore, + application, candidate, scoringCriterion, criterionScore, analysisRun, document, } from '../../../database/schema' import { scoreApplication, computeCompositeScore, hasScorableCandidateMaterial } from '../../../utils/ai/scoring' @@ -35,10 +35,18 @@ export default defineEventHandler(async (event) => { // Body is optional — GET-style "just run with defaults" stays valid. const body = await readBody(event).catch(() => null) const parsedBody = body ? bodySchema.parse(body) : null + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) // Fetch application with candidate, job, and documents const app = await db.query.application.findFirst({ - where: and(eq(application.id, applicationId), eq(application.organizationId, orgId)), + where: and( + eq(application.id, applicationId), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ), with: { candidate: { columns: { id: true, firstName: true, lastName: true }, diff --git a/server/api/applications/[id]/properties/[propId].put.ts b/server/api/applications/[id]/properties/[propId].put.ts index 46337b64..218ca2a8 100644 --- a/server/api/applications/[id]/properties/[propId].put.ts +++ b/server/api/applications/[id]/properties/[propId].put.ts @@ -1,6 +1,6 @@ -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { z } from 'zod' -import { application, propertyDefinition, propertyValue } from '../../../../database/schema' +import { application, candidate, propertyDefinition, propertyValue } from '../../../../database/schema' import { setPropertyValueSchema, validateValueForType, @@ -22,10 +22,18 @@ export default defineEventHandler(async (event) => { const { id, propId } = await getValidatedRouterParams(event, paramsSchema.parse) const { value } = await readValidatedBody(event, setPropertyValueSchema.parse) + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) // Verify entity belongs to org const app = await db.query.application.findFirst({ - where: and(eq(application.id, id), eq(application.organizationId, orgId)), + where: and( + eq(application.id, id), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ), columns: { id: true, jobId: true }, }) if (!app) throw createError({ statusCode: 404, statusMessage: 'Application not found' }) diff --git a/server/api/applications/[id]/scores.get.ts b/server/api/applications/[id]/scores.get.ts index ad8ba026..bd27deee 100644 --- a/server/api/applications/[id]/scores.get.ts +++ b/server/api/applications/[id]/scores.get.ts @@ -1,5 +1,5 @@ -import { eq, and, desc } from 'drizzle-orm' -import { application, criterionScore, analysisRun, scoringCriterion } from '../../../database/schema' +import { eq, and, desc, inArray, isNull } from 'drizzle-orm' +import { application, candidate, criterionScore, analysisRun, scoringCriterion } from '../../../database/schema' import { z } from 'zod' const paramsSchema = z.object({ id: z.string().min(1) }) @@ -13,10 +13,18 @@ export default defineEventHandler(async (event) => { const session = await requirePermission(event, { scoring: ['read'] }) const orgId = session.session.activeOrganizationId const { id: applicationId } = await getValidatedRouterParams(event, paramsSchema.parse) + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) // Verify application belongs to org const app = await db.query.application.findFirst({ - where: and(eq(application.id, applicationId), eq(application.organizationId, orgId)), + where: and( + eq(application.id, applicationId), + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ), columns: { id: true, score: true, jobId: true }, }) if (!app) { diff --git a/server/api/applications/index.get.ts b/server/api/applications/index.get.ts index 23614697..e03d39a9 100644 --- a/server/api/applications/index.get.ts +++ b/server/api/applications/index.get.ts @@ -30,7 +30,10 @@ export default defineEventHandler(async (event) => { const query = await getValidatedQuery(event, applicationQuerySchema.parse) const offset = (query.page - 1) * query.limit - const conditions = [eq(application.organizationId, orgId)] + const conditions = [ + eq(application.organizationId, orgId), + isNull(candidate.quarantinedAt), + ] if (query.jobId) { conditions.push(eq(application.jobId, query.jobId)) @@ -102,7 +105,12 @@ export default defineEventHandler(async (event) => { ? db .select({ status: application.status, count: count() }) .from(application) - .where(and(eq(application.organizationId, orgId), eq(application.jobId, query.jobId))) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .where(and( + eq(application.organizationId, orgId), + eq(application.jobId, query.jobId), + isNull(candidate.quarantinedAt), + )) .groupBy(application.status) : Promise.resolve([]) diff --git a/server/api/dashboard/stats.get.ts b/server/api/dashboard/stats.get.ts index f0d7875b..4b3d0dfc 100644 --- a/server/api/dashboard/stats.get.ts +++ b/server/api/dashboard/stats.get.ts @@ -1,4 +1,4 @@ -import { eq, and, desc, sql, count } from 'drizzle-orm' +import { eq, and, desc, sql, count, inArray, isNull } from 'drizzle-orm' import { application, candidate, job } from '../../database/schema' /** @@ -13,6 +13,14 @@ import { application, candidate, job } from '../../database/schema' export default defineEventHandler(async (event) => { const session = await requirePermission(event, { job: ['read'], candidate: ['read'], application: ['read'] }) const orgId = session.session.activeOrganizationId + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) + const activeApplicationCondition = and( + eq(application.organizationId, orgId), + inArray(application.candidateId, activeCandidateIds), + ) // ───────────────────────────────────────────── // Run all queries in parallel for performance @@ -31,13 +39,13 @@ export default defineEventHandler(async (event) => { db.$count(job, and(eq(job.organizationId, orgId), eq(job.status, 'open'))), // 2. Total candidates - db.$count(candidate, eq(candidate.organizationId, orgId)), + db.$count(candidate, and(eq(candidate.organizationId, orgId), isNull(candidate.quarantinedAt))), // 3. Total applications - db.$count(application, eq(application.organizationId, orgId)), + db.$count(application, activeApplicationCondition), // 4. New (unreviewed) applications - db.$count(application, and(eq(application.organizationId, orgId), eq(application.status, 'new'))), + db.$count(application, and(activeApplicationCondition, eq(application.status, 'new'))), // 5. Pipeline breakdown — application count per status db @@ -46,7 +54,7 @@ export default defineEventHandler(async (event) => { count: count().as('count'), }) .from(application) - .where(eq(application.organizationId, orgId)) + .where(activeApplicationCondition) .groupBy(application.status), // 6. Jobs by status @@ -75,7 +83,7 @@ export default defineEventHandler(async (event) => { .from(application) .innerJoin(candidate, eq(candidate.id, application.candidateId)) .innerJoin(job, eq(job.id, application.jobId)) - .where(eq(application.organizationId, orgId)) + .where(and(eq(application.organizationId, orgId), isNull(candidate.quarantinedAt))) .orderBy(desc(application.createdAt)) .limit(10), @@ -96,7 +104,10 @@ export default defineEventHandler(async (event) => { rejectedCount: sql`count(case when ${application.status} = 'rejected' then 1 end)`.as('rejected_count'), }) .from(job) - .leftJoin(application, eq(application.jobId, job.id)) + .leftJoin(application, and( + eq(application.jobId, job.id), + inArray(application.candidateId, activeCandidateIds), + )) .where(and(eq(job.organizationId, orgId), eq(job.status, 'open'))) .groupBy(job.id) .orderBy(sql`count(${application.id}) desc`) diff --git a/server/api/interviews/index.get.ts b/server/api/interviews/index.get.ts index f719ce63..83e3229c 100644 --- a/server/api/interviews/index.get.ts +++ b/server/api/interviews/index.get.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, gte, lte } from 'drizzle-orm' +import { and, count, desc, eq, gte, isNull, lte } from 'drizzle-orm' import { interview, application, candidate, job } from '../../database/schema' import { interviewQuerySchema } from '../../utils/schemas/interview' @@ -8,7 +8,10 @@ export default defineEventHandler(async (event) => { const query = await getValidatedQuery(event, interviewQuerySchema.parse) - const conditions = [eq(interview.organizationId, orgId)] + const conditions = [ + eq(interview.organizationId, orgId), + isNull(candidate.quarantinedAt), + ] if (query.applicationId) { conditions.push(eq(interview.applicationId, query.applicationId)) diff --git a/server/api/jobs/[id].get.ts b/server/api/jobs/[id].get.ts index 5596eb75..a5f805e2 100644 --- a/server/api/jobs/[id].get.ts +++ b/server/api/jobs/[id].get.ts @@ -1,5 +1,5 @@ -import { eq, and } from 'drizzle-orm' -import { job } from '../../database/schema' +import { eq, and, inArray, isNull } from 'drizzle-orm' +import { application, candidate, job } from '../../database/schema' import { idParamSchema } from '../../utils/schemas/job' export default defineEventHandler(async (event) => { @@ -7,6 +7,10 @@ export default defineEventHandler(async (event) => { const orgId = session.session.activeOrganizationId const { id } = await getValidatedRouterParams(event, idParamSchema.parse) + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) const result = await db.query.job.findFirst({ where: and(eq(job.id, id), eq(job.organizationId, orgId)), @@ -37,6 +41,7 @@ export default defineEventHandler(async (event) => { with: { applications: { columns: { id: true, candidateId: true, status: true, createdAt: true }, + where: inArray(application.candidateId, activeCandidateIds), limit: 100, }, }, diff --git a/server/api/jobs/[id]/analyze-all.post.ts b/server/api/jobs/[id]/analyze-all.post.ts index cf14c1e2..6f34e274 100644 --- a/server/api/jobs/[id]/analyze-all.post.ts +++ b/server/api/jobs/[id]/analyze-all.post.ts @@ -1,5 +1,5 @@ -import { eq, and, isNull } from 'drizzle-orm' -import { application, job } from '../../../database/schema' +import { eq, and, inArray, isNull } from 'drizzle-orm' +import { application, candidate, job } from '../../../database/schema' import { z } from 'zod' const paramsSchema = z.object({ id: z.string().min(1) }) @@ -24,6 +24,10 @@ export default defineEventHandler(async (event) => { if (!jobRecord) { throw createError({ statusCode: 404, statusMessage: 'Job not found' }) } + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) // Find all applications without scores const unscoredApps = await db.select({ @@ -34,6 +38,7 @@ export default defineEventHandler(async (event) => { eq(application.jobId, jobId), eq(application.organizationId, orgId), isNull(application.score), + inArray(application.candidateId, activeCandidateIds), )) return { diff --git a/server/api/jobs/index.get.ts b/server/api/jobs/index.get.ts index 7586c450..51ee7f5f 100644 --- a/server/api/jobs/index.get.ts +++ b/server/api/jobs/index.get.ts @@ -1,5 +1,5 @@ -import { eq, and, desc, count, inArray } from 'drizzle-orm' -import { job, application } from '../../database/schema' +import { eq, and, desc, count, inArray, isNull } from 'drizzle-orm' +import { job, application, candidate } from '../../database/schema' import { jobQuerySchema } from '../../utils/schemas/job' interface PipelineCounts { @@ -49,6 +49,10 @@ export default defineEventHandler(async (event) => { let pipelineMap: Record = {} if (jobIds.length > 0) { + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, orgId), + isNull(candidate.quarantinedAt), + )) const pipelineRows = await db .select({ jobId: application.jobId, @@ -59,6 +63,7 @@ export default defineEventHandler(async (event) => { .where(and( eq(application.organizationId, orgId), inArray(application.jobId, jobIds), + inArray(application.candidateId, activeCandidateIds), )) .groupBy(application.jobId, application.status) diff --git a/server/api/tracking-links/[id]/stats.get.ts b/server/api/tracking-links/[id]/stats.get.ts index 909d3709..e43044aa 100644 --- a/server/api/tracking-links/[id]/stats.get.ts +++ b/server/api/tracking-links/[id]/stats.get.ts @@ -1,4 +1,4 @@ -import { eq, and, sql, count, gte, lte, desc } from 'drizzle-orm' +import { eq, and, sql, count, gte, isNull, lte, desc } from 'drizzle-orm' import { applicationSource, application, trackingLink, job, candidate } from '../../../database/schema' import { trackingLinkIdSchema, sourceStatsQuerySchema } from '../../../utils/schemas/trackingLink' @@ -38,7 +38,10 @@ export default defineEventHandler(async (event) => { } // ─── Date conditions ────────────────────── - const dateConditions = [eq(applicationSource.trackingLinkId, id)] + const dateConditions = [ + eq(applicationSource.trackingLinkId, id), + isNull(candidate.quarantinedAt), + ] if (query.from) { dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) } @@ -64,6 +67,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(whereClause) .groupBy(application.status), @@ -75,6 +79,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(whereClause) .groupBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`) .orderBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`), @@ -114,6 +119,7 @@ export default defineEventHandler(async (event) => { }) .from(applicationSource) .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) .where(and( ...dateConditions, sql`${applicationSource.referrerDomain} IS NOT NULL`, @@ -123,7 +129,16 @@ export default defineEventHandler(async (event) => { .limit(10), // 5. Total attributed count - db.$count(applicationSource, eq(applicationSource.trackingLinkId, id)), + db + .select({ count: count() }) + .from(applicationSource) + .innerJoin(application, eq(application.id, applicationSource.applicationId)) + .innerJoin(candidate, eq(candidate.id, application.candidateId)) + .where(and( + eq(applicationSource.trackingLinkId, id), + isNull(candidate.quarantinedAt), + )) + .then(rows => Number(rows[0]?.count ?? 0)), ]) // ─── Build funnel map ───────────────────── @@ -134,7 +149,7 @@ export default defineEventHandler(async (event) => { // ─── Conversion rate ────────────────────── const cvr = link.clickCount > 0 - ? Math.round((link.applicationCount / link.clickCount) * 100) + ? Math.round((totalAttributed / link.clickCount) * 100) : 0 return { @@ -151,7 +166,7 @@ export default defineEventHandler(async (event) => { utmTerm: link.utmTerm, utmContent: link.utmContent, clickCount: link.clickCount, - applicationCount: link.applicationCount, + applicationCount: totalAttributed, isActive: link.isActive, createdAt: link.createdAt, cvr, diff --git a/server/utils/ai/chatTools.ts b/server/utils/ai/chatTools.ts index 8310da15..9ea8b5d0 100644 --- a/server/utils/ai/chatTools.ts +++ b/server/utils/ai/chatTools.ts @@ -12,7 +12,7 @@ * `list_*` to discover IDs, then `get_*` to fetch details. */ import { tool } from 'ai' -import { and, desc, eq, ilike, inArray, or } from 'drizzle-orm' +import { and, desc, eq, ilike, inArray, isNull, or } from 'drizzle-orm' import { z } from 'zod' import { application, @@ -62,6 +62,11 @@ function truncate(text: string, max: number): string { } export function buildChatbotTools(ctx: ChatbotToolContext) { + const activeCandidateIds = db.select({ id: candidate.id }).from(candidate).where(and( + eq(candidate.organizationId, ctx.orgId), + isNull(candidate.quarantinedAt), + )) + return { list_jobs: tool({ description: @@ -157,6 +162,7 @@ export function buildChatbotTools(ctx: ChatbotToolContext) { const conditions = [ eq(application.organizationId, ctx.orgId), eq(application.jobId, jobId), + inArray(application.candidateId, activeCandidateIds), ] if (status) conditions.push(eq(application.status, status)) @@ -195,6 +201,7 @@ export function buildChatbotTools(ctx: ChatbotToolContext) { const rows = await db.query.candidate.findMany({ where: and( eq(candidate.organizationId, ctx.orgId), + isNull(candidate.quarantinedAt), or( ilike(candidate.firstName, like), ilike(candidate.lastName, like), @@ -250,6 +257,7 @@ export function buildChatbotTools(ctx: ChatbotToolContext) { where: and( eq(candidate.organizationId, ctx.orgId), eq(candidate.id, candidateId), + isNull(candidate.quarantinedAt), ), }) if (!c) throw new Error(`Candidate ${candidateId} not found.`) @@ -345,6 +353,7 @@ export function buildChatbotTools(ctx: ChatbotToolContext) { where: and( eq(document.organizationId, ctx.orgId), eq(document.id, documentId), + inArray(document.candidateId, activeCandidateIds), ), }) if (!doc) throw new Error(`Document ${documentId} not found.`) @@ -398,6 +407,7 @@ export function buildChatbotTools(ctx: ChatbotToolContext) { where: and( eq(application.organizationId, ctx.orgId), eq(application.id, applicationId), + inArray(application.candidateId, activeCandidateIds), ), columns: { id: true, score: true, jobId: true }, }) diff --git a/tests/unit/application-deletion.test.ts b/tests/unit/application-deletion.test.ts new file mode 100644 index 00000000..6f262574 --- /dev/null +++ b/tests/unit/application-deletion.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = (event: unknown) => Promise + +let handler: Handler + +beforeAll(async () => { + vi.stubGlobal('defineEventHandler', (value: Handler) => value) + handler = (await import('../../server/api/applications/[id].delete')).default as Handler +}) + +function httpError(input: { statusCode: number, statusMessage: string }) { + return Object.assign(new Error(input.statusMessage), input) +} + +function makeDb(options: { + applicationExists?: boolean + interviews?: Array<{ + id: string + status: 'scheduled' | 'completed' | 'cancelled' | 'no_show' + invitationSentAt: Date | null + googleCalendarEventId: string | null + createdById: string + }> + attachmentKeys?: string[] +} = {}) { + const txDelete = vi.fn(() => ({ + where: vi.fn(() => Object.assign(Promise.resolve(), { + returning: vi.fn(async () => [{ id: 'application-1' }]), + })), + })) + const transaction = vi.fn(async (callback: (tx: { delete: typeof txDelete }) => Promise) => { + await callback({ delete: txDelete }) + }) + + return { + txDelete, + transaction, + db: { + query: { + application: { + findFirst: vi.fn(async () => options.applicationExists === false ? undefined : { id: 'application-1' }), + }, + interview: { + findMany: vi.fn(async () => options.interviews ?? []), + }, + candidateConversation: { + findMany: vi.fn(async () => options.attachmentKeys?.length ? [{ id: 'conversation-1' }] : []), + }, + candidateMessage: { + findMany: vi.fn(async () => options.attachmentKeys?.length ? [{ id: 'message-1' }] : []), + }, + candidateMessageAttachment: { + findMany: vi.fn(async () => (options.attachmentKeys ?? []).map(storageKey => ({ storageKey }))), + }, + }, + transaction, + }, + } +} + +beforeEach(() => { + vi.stubGlobal('requirePermission', vi.fn(async () => ({ + session: { activeOrganizationId: 'org-1' }, + user: { id: 'user-1' }, + }))) + vi.stubGlobal('getValidatedRouterParams', vi.fn(async () => ({ id: 'application-1' }))) + vi.stubGlobal('createError', httpError) + vi.stubGlobal('setResponseStatus', vi.fn()) + vi.stubGlobal('recordActivity', vi.fn()) + vi.stubGlobal('deleteFromS3', vi.fn(async () => {})) + vi.stubGlobal('logWarn', vi.fn()) + vi.stubGlobal('logError', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('DELETE /api/applications/:id', () => { + it('requires application delete permission and removes the dependent graph transactionally', async () => { + const mock = makeDb() + vi.stubGlobal('db', mock.db) + + await expect(handler({})).resolves.toBeNull() + + expect(requirePermission).toHaveBeenCalledWith({}, { application: ['delete'] }) + expect(mock.transaction).toHaveBeenCalledOnce() + expect(mock.txDelete).toHaveBeenCalledTimes(3) + expect(setResponseStatus).toHaveBeenCalledWith({}, 204) + expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ + organizationId: 'org-1', + action: 'deleted', + resourceType: 'application', + resourceId: 'application-1', + })) + }) + + it('returns 404 without deleting when the application is outside the organization', async () => { + const mock = makeDb({ applicationExists: false }) + vi.stubGlobal('db', mock.db) + + await expect(handler({})).rejects.toMatchObject({ statusCode: 404 }) + expect(mock.transaction).not.toHaveBeenCalled() + }) + + it('requires an invited scheduled interview to be cancelled first', async () => { + const mock = makeDb({ + interviews: [{ + id: 'interview-1', + status: 'scheduled', + invitationSentAt: new Date(), + googleCalendarEventId: null, + createdById: 'user-1', + }], + }) + vi.stubGlobal('db', mock.db) + + await expect(handler({})).rejects.toMatchObject({ statusCode: 409 }) + expect(mock.transaction).not.toHaveBeenCalled() + }) + + it('best-effort removes message attachments without blocking database deletion', async () => { + const mock = makeDb({ attachmentKeys: ['applications/message.txt'] }) + vi.stubGlobal('db', mock.db) + vi.mocked(deleteFromS3).mockRejectedValueOnce(new Error('storage unavailable')) + + await expect(handler({})).resolves.toBeNull() + + expect(deleteFromS3).toHaveBeenCalledWith('applications/message.txt') + expect(logWarn).toHaveBeenCalledWith( + 'application.attachment_delete_failed', + expect.objectContaining({ application_id: 'application-1' }), + ) + expect(mock.transaction).toHaveBeenCalledOnce() + }) +}) From dcc38e9977c39a389f9d4500281e2353c72253d8 Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 15:08:14 +0200 Subject: [PATCH 08/12] Defer S3 file deletion until after database transaction Move the S3 object deletion logic after the database transaction to prevent orphaned database records if the transaction fails. --- server/api/applications/[id].delete.ts | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/server/api/applications/[id].delete.ts b/server/api/applications/[id].delete.ts index ee8e71e5..b1c44bd4 100644 --- a/server/api/applications/[id].delete.ts +++ b/server/api/applications/[id].delete.ts @@ -78,20 +78,6 @@ export default defineEventHandler(async (event) => { }) : [] - for (const attachment of attachments) { - try { - await deleteFromS3(attachment.storageKey) - } - catch (error) { - logWarn('application.attachment_delete_failed', { - organization_id: orgId, - application_id: id, - storage_key: attachment.storageKey, - error_message: error instanceof Error ? error.message : String(error), - }) - } - } - await db.transaction(async (tx) => { await tx.delete(comment).where(and( eq(comment.organizationId, orgId), @@ -112,6 +98,23 @@ export default defineEventHandler(async (event) => { } }) + // The DB rows are gone; now clean up the S3 objects. Doing this after the + // commit avoids orphaning attachment rows against deleted storage if the + // transaction rolls back. + for (const attachment of attachments) { + try { + await deleteFromS3(attachment.storageKey) + } + catch (error) { + logWarn('application.attachment_delete_failed', { + organization_id: orgId, + application_id: id, + storage_key: attachment.storageKey, + error_message: error instanceof Error ? error.message : String(error), + }) + } + } + for (const row of applicationInterviews) { if (!row.googleCalendarEventId) continue cancelCalendarEvent(row.createdById, row.googleCalendarEventId).catch((error) => { From 2f88a4154e81c6535843373401f21c0b61b6c36a Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Mon, 20 Jul 2026 15:31:32 +0200 Subject: [PATCH 09/12] Move notification enqueuing outside of domain transactions Decouple notification logic from database transactions to ensure that notification delivery failures cannot roll back primary business operations. Updated all notification producers to enqueue events after the DB transaction commits. --- ee/server/api/webhooks/resend.post.ts | 35 +++++++++--------- server/api/public/interviews/respond.post.ts | 36 ++++++++++--------- server/api/public/jobs/[slug]/apply.post.ts | 31 ++++++++-------- server/utils/notifications/enqueue.ts | 15 ++++---- .../utils/notifications/interview-response.ts | 3 -- tests/unit/notification-enqueue.test.ts | 23 +++++++----- 6 files changed, 72 insertions(+), 71 deletions(-) diff --git a/ee/server/api/webhooks/resend.post.ts b/ee/server/api/webhooks/resend.post.ts index ee6804b1..9d676809 100644 --- a/ee/server/api/webhooks/resend.post.ts +++ b/ee/server/api/webhooks/resend.post.ts @@ -205,7 +205,8 @@ async function processInboundMessage( } const createdAt = new Date(data.created_at) const bodyText = inboundTextContent(data.text, data.html) - const { storedMessage } = await db.transaction(async (tx) => { + const candidateName = `${conversation.application.candidate.firstName} ${conversation.application.candidate.lastName}`.trim() + const { storedMessage, shouldNotify } = await db.transaction(async (tx) => { const [inserted] = await tx.insert(candidateMessage).values({ organizationId: conversation.organizationId, conversationId: conversation.id, @@ -238,25 +239,25 @@ async function processInboundMessage( lastMessageAt: createdAt, updatedAt: new Date(), }).where(eq(candidateConversation.id, conversation.id)) - - const candidateName = `${conversation.application.candidate.firstName} ${conversation.application.candidate.lastName}`.trim() - await enqueueNotification({ - organizationId: conversation.organizationId, - type: 'candidate_replied', - dedupeKey: `candidate_replied:${storedMessage.id}`, - payload: { - applicationId: conversation.applicationId, - conversationId: conversation.id, - candidateName, - jobTitle: conversation.application.job.title, - preview: bodyText.slice(0, 200), - }, - tx, - }) } - return { storedMessage } + return { storedMessage, shouldNotify: Boolean(inserted) } }) + if (shouldNotify) { + await enqueueNotification({ + organizationId: conversation.organizationId, + type: 'candidate_replied', + dedupeKey: `candidate_replied:${storedMessage.id}`, + payload: { + applicationId: conversation.applicationId, + conversationId: conversation.id, + candidateName, + jobTitle: conversation.application.job.title, + preview: bodyText.slice(0, 200), + }, + }) + } + if (data.attachments.length > 0) { await captureInboundAttachments({ resend, diff --git a/server/api/public/interviews/respond.post.ts b/server/api/public/interviews/respond.post.ts index 76656ec7..ca78d112 100644 --- a/server/api/public/interviews/respond.post.ts +++ b/server/api/public/interviews/respond.post.ts @@ -80,7 +80,7 @@ export default defineEventHandler(async (event) => { ? 'Requested another time' : 'Declined the interview' - const updated = await db.transaction(async (tx) => { + const { updated, shouldNotify } = await db.transaction(async (tx) => { // Serialize concurrent responses for this interview. Two flips arriving at // once must not both pass the change check below and double-insert a // response message (or double-count the unread badge). @@ -95,9 +95,12 @@ export default defineEventHandler(async (event) => { if (!current || current.candidateResponse === action) { return { - id: payload.id, - candidateResponse: current?.candidateResponse ?? action, - candidateRespondedAt: current?.candidateRespondedAt ?? respondedAt, + updated: { + id: payload.id, + candidateResponse: current?.candidateResponse ?? action, + candidateRespondedAt: current?.candidateRespondedAt ?? respondedAt, + }, + shouldNotify: false, } } @@ -150,21 +153,20 @@ export default defineEventHandler(async (event) => { }).where(eq(candidateConversation.id, conversation.id)) } } - if (result && app?.candidate && app.job) { - await enqueueInterviewResponseNotification({ - organizationId: interviewRecord.organizationId, - interviewId: interviewRecord.id, - candidateName: `${app.candidate.firstName} ${app.candidate.lastName}`.trim() || app.candidate.email, - jobTitle: app.job.title, - interviewTitle: interviewRecord.title, - response: action, - tx, - }) - } - - return result + return { updated: result, shouldNotify: Boolean(result) } }) + if (shouldNotify && app?.candidate && app.job) { + await enqueueInterviewResponseNotification({ + organizationId: interviewRecord.organizationId, + interviewId: interviewRecord.id, + candidateName: `${app.candidate.firstName} ${app.candidate.lastName}`.trim() || app.candidate.email, + jobTitle: app.job.title, + interviewTitle: interviewRecord.title, + response: action, + }) + } + return { success: true, interviewId: updated?.id, diff --git a/server/api/public/jobs/[slug]/apply.post.ts b/server/api/public/jobs/[slug]/apply.post.ts index b17ead66..6787c5ff 100644 --- a/server/api/public/jobs/[slug]/apply.post.ts +++ b/server/api/public/jobs/[slug]/apply.post.ts @@ -420,31 +420,28 @@ export default defineEventHandler(async (event) => { // ───────────────────────────────────────────── const [newApplication] = await db.transaction(async (tx) => { - const inserted = await tx.insert(application).values({ + return tx.insert(application).values({ organizationId: orgId, candidateId, jobId, status: 'new', coverLetterText: coverLetterText || null, }).returning({ id: application.id }) - - const row = inserted[0] - if (row) { - await enqueueNotification({ - organizationId: orgId, - type: 'application_created', - dedupeKey: `application_created:${row.id}`, - payload: { - applicationId: row.id, - candidateName: `${firstName} ${lastName}`.trim(), - jobTitle: existingJob.title, - }, - tx, - }) - } - return inserted }) + if (newApplication) { + await enqueueNotification({ + organizationId: orgId, + type: 'application_created', + dedupeKey: `application_created:${newApplication.id}`, + payload: { + applicationId: newApplication.id, + candidateName: `${firstName} ${lastName}`.trim(), + jobTitle: existingJob.title, + }, + }) + } + // ───────────────────────────────────────────── // 8b. Record source attribution // ───────────────────────────────────────────── diff --git a/server/utils/notifications/enqueue.ts b/server/utils/notifications/enqueue.ts index 5ba0930a..16c6a689 100644 --- a/server/utils/notifications/enqueue.ts +++ b/server/utils/notifications/enqueue.ts @@ -2,16 +2,16 @@ * Enqueue a recruiter notification event into the durable outbox. * * Modeled on `recordActivity` — it never throws, so a notification failure can - * never break the primary write. Accepts an optional transaction so critical - * events are enqueued atomically with the triggering write: if the tx rolls - * back, the outbox rows roll back with it. + * never break the primary write. Producers must call it after their domain + * transaction commits; it deliberately uses the standalone database client so + * an outbox failure cannot poison or roll back that transaction. * * Idempotency: each recipient row's `dedupeKey` is `:`, and * inserts use `onConflictDoNothing`, so re-running the same producer is a no-op. */ import { notificationOutbox } from '../../database/schema/app' import type { NotificationType } from '~~/shared/notifications' -import { resolveRecipients, type NotificationDbClient } from './recipients' +import { resolveRecipients } from './recipients' const DIGEST_HOUR_UTC = 8 @@ -33,11 +33,9 @@ export async function enqueueNotification(params: { /** Event-scoped idempotency prefix, e.g. `application_created:`. */ dedupeKey: string payload: Record - tx?: NotificationDbClient }): Promise { - const dbc = params.tx ?? db try { - const recipients = await resolveRecipients(params.organizationId, params.type, dbc) + const recipients = await resolveRecipients(params.organizationId, params.type) if (recipients.length === 0) return const bucket = digestBucketFor() @@ -53,11 +51,10 @@ export async function enqueueNotification(params: { nextAttemptAt: r.cadence === 'digest' ? digestDeliveryAt(bucket) : new Date(), })) - await dbc.insert(notificationOutbox).values(rows) + await db.insert(notificationOutbox).values(rows) .onConflictDoNothing({ target: notificationOutbox.dedupeKey }) } catch (err) { - if (params.tx) throw err // Enqueue must never break the primary operation. logWarn('notification.enqueue_failed', { organization_id: params.organizationId, diff --git a/server/utils/notifications/interview-response.ts b/server/utils/notifications/interview-response.ts index ec10d9cd..56298cbd 100644 --- a/server/utils/notifications/interview-response.ts +++ b/server/utils/notifications/interview-response.ts @@ -1,4 +1,3 @@ -import type { NotificationDbClient } from './recipients' import { enqueueNotification } from './enqueue' export type RecruiterInterviewResponse = 'accepted' | 'declined' | 'tentative' @@ -11,7 +10,6 @@ export async function enqueueInterviewResponseNotification(params: { jobTitle: string interviewTitle: string response: RecruiterInterviewResponse - tx?: NotificationDbClient }): Promise { await enqueueNotification({ organizationId: params.organizationId, @@ -24,6 +22,5 @@ export async function enqueueInterviewResponseNotification(params: { interviewTitle: params.interviewTitle, response: params.response, }, - tx: params.tx, }) } diff --git a/tests/unit/notification-enqueue.test.ts b/tests/unit/notification-enqueue.test.ts index 8990a50d..fab07fbd 100644 --- a/tests/unit/notification-enqueue.test.ts +++ b/tests/unit/notification-enqueue.test.ts @@ -64,22 +64,29 @@ describe('enqueueNotification', () => { expect((digest.nextAttemptAt as Date).toISOString()).toBe(`${digest.digestBucket}T08:00:00.000Z`) }) - it('propagates failures when participating in a domain transaction', async () => { + it('logs and swallows recipient resolution failures', async () => { recipientsMock.mockRejectedValue(new Error('database unavailable')) - const tx = {} as never + vi.stubGlobal('db', {}) await expect(enqueueNotification({ organizationId: 'org1', type: 'candidate_replied', dedupeKey: 'candidate_replied:m1', payload: {}, - tx, - })).rejects.toThrow('database unavailable') + })).resolves.toBeUndefined() + expect(logWarn).toHaveBeenCalledWith('notification.enqueue_failed', expect.any(Object)) }) - it('logs and swallows failures outside a transaction', async () => { - recipientsMock.mockRejectedValue(new Error('database unavailable')) - vi.stubGlobal('db', {}) + it('logs and swallows outbox insertion failures', async () => { + vi.stubGlobal('db', { + insert: () => ({ + values: () => ({ + onConflictDoNothing: async () => { + throw new Error('database unavailable') + }, + }), + }), + }) await expect(enqueueNotification({ organizationId: 'org1', @@ -95,6 +102,7 @@ describe('enqueueNotification', () => { { userId: 'u1', email: 'one@example.com', cadence: 'instant' }, ]) const { db, stored } = insertionDb() + vi.stubGlobal('db', db) await enqueueInterviewResponseNotification({ organizationId: 'org1', @@ -103,7 +111,6 @@ describe('enqueueNotification', () => { jobTitle: 'Engineer', interviewTitle: 'Technical interview', response: 'accepted', - tx: db as never, }) expect(stored.get('interview_response:interview1:accepted:u1')).toMatchObject({ From d16dde47d2f16baf836f77c43841df777caed29c Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Wed, 22 Jul 2026 10:15:00 +0200 Subject: [PATCH 10/12] Disable onboarding survey for demo account --- server/api/onboarding-survey/index.get.ts | 6 ++++++ tests/unit/demo-account-isolation.test.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/server/api/onboarding-survey/index.get.ts b/server/api/onboarding-survey/index.get.ts index 936b9a6f..121d469c 100644 --- a/server/api/onboarding-survey/index.get.ts +++ b/server/api/onboarding-survey/index.get.ts @@ -12,6 +12,12 @@ export default defineEventHandler(async (event) => { const session = await requireAuth(event) const userId = session.user.id + // The shared demo account never gets the onboarding survey (nor its + // re-prompt banner) — it's a read-only showcase, not a real signup. + if (isDemoAccountEmail(session.user.email)) { + return { completed: true } + } + const row = await db.query.onboardingSurveyResponse.findFirst({ where: eq(onboardingSurveyResponse.userId, userId), columns: { completedAt: true }, diff --git a/tests/unit/demo-account-isolation.test.ts b/tests/unit/demo-account-isolation.test.ts index 7202ae86..0621487c 100644 --- a/tests/unit/demo-account-isolation.test.ts +++ b/tests/unit/demo-account-isolation.test.ts @@ -28,6 +28,13 @@ describe('demo account organization isolation', () => { expect(read('server/api/join-requests/index.post.ts')).toMatch(/The demo account cannot request access/) }) + it('suppresses the onboarding survey (and its re-prompt banner) for the demo account', () => { + const surveyGet = read('server/api/onboarding-survey/index.get.ts') + + expect(surveyGet).toMatch(/isDemoAccountEmail\(session\.user\.email\)/) + expect(surveyGet).toMatch(/return \{ completed: true \}/) + }) + it('keeps demo account email notification preferences disabled', () => { const seed = read('server/scripts/seed.ts') From 94f85eb2585bca19665718d7165789a1504ef1ad Mon Sep 17 00:00:00 2001 From: Joachim LK Date: Wed, 22 Jul 2026 10:25:52 +0200 Subject: [PATCH 11/12] Refactor UI and update dependencies - Add `ring-1` and `rounded-t-xl` to DemoUpsellBanner - Add `cursor-pointer` to table action buttons - Update `axios` and `tar` versions in overrides - Regenerate package-lock.json with peer dependency updates --- app/components/DemoUpsellBanner.vue | 4 +- app/pages/dashboard/applications/index.vue | 2 +- app/pages/dashboard/candidates/index.vue | 2 +- package-lock.json | 218 ++++++++------------- package.json | 4 +- 5 files changed, 90 insertions(+), 140 deletions(-) diff --git a/app/components/DemoUpsellBanner.vue b/app/components/DemoUpsellBanner.vue index d614dd0b..b46c75ec 100644 --- a/app/components/DemoUpsellBanner.vue +++ b/app/components/DemoUpsellBanner.vue @@ -31,9 +31,9 @@ const collapsed = useState('demo-upsell-collapsed', () => false)
-
+
diff --git a/app/pages/dashboard/applications/index.vue b/app/pages/dashboard/applications/index.vue index 74578216..bc9b205d 100644 --- a/app/pages/dashboard/applications/index.vue +++ b/app/pages/dashboard/applications/index.vue @@ -671,7 +671,7 @@ async function handleApplicationDeleted() {