feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
+585 -9
View File
@@ -186,6 +186,92 @@ export const analyticsLoginEvents = pgTable(
]
);
export const analyticsPageEvent = pgTable(
'analytics_page_event',
{
id: uuid().defaultRandom().primaryKey().notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
orgId: uuid('org_id'),
sessionId: text('session_id').notNull(),
userId: uuid('user_id'),
eventType: text('event_type').notNull(),
courseId: uuid('course_id'),
path: text('path'),
referrerHost: text('referrer_host'),
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
country: varchar('country', { length: 2 }),
deviceType: varchar('device_type', { length: 16 }),
locale: varchar('locale', { length: 8 }),
props: jsonb().default({}).notNull()
},
(table) => [
index('idx_ape_org_occurred').on(table.orgId, table.occurredAt),
index('idx_ape_course_occurred').on(table.courseId, table.occurredAt),
index('idx_ape_session').on(table.sessionId),
index('idx_ape_event_type').on(table.eventType),
index('idx_ape_user').on(table.userId)
]
);
export const analyticsOrgDaily = pgTable(
'analytics_org_daily',
{
id: uuid().defaultRandom().primaryKey().notNull(),
orgId: uuid('org_id').notNull(),
date: date('date').notNull(),
landingViews: integer('landing_views').default(0).notNull(),
uniqueVisitors: integer('unique_visitors').default(0).notNull(),
coursePageViews: integer('course_page_views').default(0).notNull(),
enrollments: integer('enrollments').default(0).notNull(),
completions: integer('completions').default(0).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
unique('analytics_org_daily_org_date_unique').on(table.orgId, table.date),
index('idx_aod_org_date').on(table.orgId, table.date)
]
);
export const analyticsCourseDaily = pgTable(
'analytics_course_daily',
{
id: uuid().defaultRandom().primaryKey().notNull(),
courseId: uuid('course_id').notNull(),
orgId: uuid('org_id').notNull(),
date: date('date').notNull(),
views: integer('views').default(0).notNull(),
uniqueVisitors: integer('unique_visitors').default(0).notNull(),
enrollments: integer('enrollments').default(0).notNull(),
completions: integer('completions').default(0).notNull(),
avgTimeToEnrollSeconds: integer('avg_time_to_enroll_seconds'),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
unique('analytics_course_daily_course_date_unique').on(table.courseId, table.date),
index('idx_acd_org_date').on(table.orgId, table.date),
index('idx_acd_course_date').on(table.courseId, table.date)
]
);
export const analyticsCountryDaily = pgTable(
'analytics_country_daily',
{
id: uuid().defaultRandom().primaryKey().notNull(),
orgId: uuid('org_id').notNull(),
date: date('date').notNull(),
country: varchar('country', { length: 2 }).notNull(),
views: integer('views').default(0).notNull(),
enrollments: integer('enrollments').default(0).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
unique('analytics_country_daily_org_date_country_unique').on(table.orgId, table.date, table.country),
index('idx_acntd_org_date').on(table.orgId, table.date)
]
);
export const courseSection = pgTable(
'course_section',
{
@@ -600,6 +686,27 @@ export const course = pgTable(
allowNewStudent: boolean;
sectionDisplay?: Record<string, boolean>;
isContentGroupingEnabled?: boolean;
/** Per-course AI tutor override; missing fields inherit from org defaults. */
aiTutor?: {
inheritFromOrg?: boolean;
enabled?: boolean;
persona?: 'friendly' | 'formal' | 'encouraging' | 'socratic' | 'custom';
customPersona?: string;
responseLength?: 'short' | 'medium' | 'long';
welcomeMessage?: string;
disclaimerFooter?: string;
thingsToSay?: string;
thingsNotToSay?: string;
forbiddenTopics?: string[];
groundingScope?: 'lesson' | 'course' | 'workspace';
requireCitations?: boolean;
assessmentMode?: 'direct_answer' | 'hint_only' | 'block_during_exercise';
revealSolutionsAfterAttempts?: number;
codePolicy?: 'allowed' | 'hints_only' | 'forbidden';
blockOffTopic?: boolean;
profanityFilter?: boolean;
escalation?: { enabled?: boolean; email?: string };
};
}>(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
cost: bigint({ mode: 'number' }).default(sql`'0'`),
@@ -608,7 +715,17 @@ export const course = pgTable(
isPublished: boolean('is_published').default(false),
certificate: jsonb().default({}).$type<{
isDownloadable?: boolean;
/** @deprecated Use `design.templateId`. Legacy 6-theme id; mapped on read via LEGACY_THEME_MAP. */
theme?: string;
/** Atelier-era certificate design. Source of truth for new courses. */
design?: {
templateId: 'classique' | 'brutalist' | 'noir' | 'poster' | 'minimal';
accentColor: string;
subtitle?: string;
descriptionOverride?: string;
signatories: [{ name: string; role: string }, { name: string; role: string }];
idFormat?: string;
};
deadline?: string | null;
threshold?: number;
requiredExerciseId?: string | null;
@@ -631,6 +748,7 @@ export const course = pgTable(
description: string;
buttonLabel: string;
buttonUrl: string;
animation?: 'waves' | 'dotted' | 'none';
} | null>()
},
(table) => [
@@ -1904,13 +2022,41 @@ export const organization = pgTable(
openInNewTab: boolean;
}>;
};
footer?: {
facebook: string;
instagram: string;
twitter: string;
linkedin: string;
show: boolean;
};
/** Legacy social URLs-only footer block */
footer?:
| {
facebook?: string;
instagram?: string;
twitter?: string;
linkedin?: string;
show?: boolean;
}
| {
brand: {
tagline?: string;
copyright?: string;
socials: Array<{ platform: string; href: string }>;
};
columns: Array<{
id: string;
heading: string;
links: Array<{ label: string; href: string }>;
cta?: { label: string; href: string };
}>;
bottom?: {
text?: string;
links: Array<{ label: string; href: string }>;
};
};
/** Current landing JSON shape (stored alongside legacy keys above) */
theme?: string;
hero?: Record<string, unknown>;
navItems?: Array<{ label: string; href: string }>;
footerLinks?: Array<{ label: string; href: string }>;
footerText?: string;
embed?: Record<string, unknown>;
callout?: Record<string, unknown>;
links?: Record<string, unknown>;
}>(),
theme: text().default('blue'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
@@ -1947,11 +2093,59 @@ export const organization = pgTable(
disableSignup: boolean('disable_signup').default(false),
disableSignupMessage: text('disable_signup_message'),
disableEmailPassword: boolean('disable_email_password').default(false),
disableGoogleAuth: boolean('disable_google_auth').default(false)
disableGoogleAuth: boolean('disable_google_auth').default(false),
parentOrganizationId: uuid('parent_organization_id'),
readOnlyUntil: timestamp('read_only_until', { withTimezone: true, mode: 'string' }),
aiTutorSettings: jsonb('ai_tutor_settings')
.default({
enabled: true,
persona: 'encouraging',
customPersona: '',
responseLength: 'medium',
welcomeMessage: '',
disclaimerFooter: 'I am an AI tutor, not your instructor.',
thingsToSay: '',
thingsNotToSay: '',
forbiddenTopics: [],
groundingScope: 'course',
requireCitations: true,
assessmentMode: 'hint_only',
revealSolutionsAfterAttempts: 3,
codePolicy: 'hints_only',
blockOffTopic: true,
profanityFilter: true,
escalation: { enabled: false, email: '' }
})
.$type<{
enabled: boolean;
persona: 'friendly' | 'formal' | 'encouraging' | 'socratic' | 'custom';
customPersona: string;
responseLength: 'short' | 'medium' | 'long';
welcomeMessage: string;
disclaimerFooter: string;
thingsToSay: string;
thingsNotToSay: string;
forbiddenTopics: string[];
groundingScope: 'lesson' | 'course' | 'workspace';
requireCitations: boolean;
assessmentMode: 'direct_answer' | 'hint_only' | 'block_during_exercise';
revealSolutionsAfterAttempts: number;
codePolicy: 'allowed' | 'hints_only' | 'forbidden';
blockOffTopic: boolean;
profanityFilter: boolean;
escalation: { enabled: boolean; email: string };
}>()
.notNull()
},
(table) => [
unique('organization_siteName_key').on(table.siteName),
unique('organization_customDomain_key').on(table.customDomain)
unique('organization_customDomain_key').on(table.customDomain),
foreignKey({
columns: [table.parentOrganizationId],
foreignColumns: [table.id],
name: 'organization_parent_organization_id_fkey'
}).onDelete('set null'),
index('idx_organization_parent_id').on(table.parentOrganizationId)
]
);
@@ -2593,6 +2787,127 @@ export const programNewsfeedComment = pgTable(
]
);
// ─── Program Goals ────────────────────────────────────────────────────────────
//
// A team-level "is the team done" target. A small generalisation of the
// per-course `course.compliance` field: applies across multiple courses, scoped
// to a program's members, and aggregated for owner roll-ups. Per-learner status
// is materialised in `programGoalAssignment` so dashboards stay cheap.
export const programGoalType = pgEnum('PROGRAM_GOAL_TYPE', [
'complete_all',
'n_of_m',
'score',
'pass_rate',
'readiness'
]);
export const programGoalDeadlineKind = pgEnum('PROGRAM_GOAL_DEADLINE_KIND', [
'absolute',
'relative_to_join',
'recurring',
'none'
]);
export const programGoalStatus = pgEnum('PROGRAM_GOAL_STATUS', ['active', 'archived']);
export const programGoalAssignmentStatus = pgEnum('PROGRAM_GOAL_ASSIGNMENT_STATUS', [
'not_started',
'in_progress',
'completed',
'at_risk',
'overdue',
'waived'
]);
export const programGoal = pgTable(
'program_goal',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
programId: uuid('program_id').notNull(),
title: varchar({ length: 255 }).notNull(),
description: text(),
type: programGoalType().notNull(),
/** Course IDs the goal targets. JSONB so we can attach per-course config later if needed. */
courseIds: jsonb('course_ids').$type<string[]>().default([]).notNull(),
/** Required only for n_of_m. */
requiredCount: integer('required_count'),
/** Per-learner score threshold (0–100). Required for `score` and `pass_rate`. */
scoreThreshold: integer('score_threshold'),
/** Team-wide pass-rate threshold (0–100). Required for `pass_rate`. */
teamPassRateThreshold: integer('team_pass_rate_threshold'),
deadlineKind: programGoalDeadlineKind('deadline_kind').default('none').notNull(),
/** Used when deadlineKind = 'absolute'. */
deadlineDate: timestamp('deadline_date', { withTimezone: true, mode: 'string' }),
/** Used when deadlineKind = 'relative_to_join'. */
relativeDays: integer('relative_days'),
/** Used when deadlineKind = 'recurring'. */
recurringMonths: integer('recurring_months'),
/** Reminder cadence (days before due). Mirrors `course.compliance.reminderDaysBefore`. */
reminderDaysBefore: jsonb('reminder_days_before').$type<number[]>().default([7, 1]).notNull(),
status: programGoalStatus().default('active').notNull(),
createdByProfileId: uuid('created_by_profile_id'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.programId],
foreignColumns: [program.id],
name: 'program_goal_program_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByProfileId],
foreignColumns: [profile.id],
name: 'program_goal_created_by_profile_id_fkey'
}).onDelete('set null'),
index('idx_program_goal_program_id').on(table.programId),
index('idx_program_goal_status').on(table.status)
]
);
export const programGoalAssignment = pgTable(
'program_goal_assignment',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
goalId: uuid('goal_id').notNull(),
programMemberId: uuid('program_member_id').notNull(),
/** Resolved due date for this learner (null when goal has no deadline). */
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }),
status: programGoalAssignmentStatus().default('not_started').notNull(),
/** Cached count of completed required courses (for n_of_m / complete_all dashboards). */
completedCount: integer('completed_count').default(0).notNull(),
/** Cached numerator for the requirement (e.g. courses required to complete). */
requiredCount: integer('required_count').default(0).notNull(),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
lastEvaluatedAt: timestamp('last_evaluated_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.goalId],
foreignColumns: [programGoal.id],
name: 'program_goal_assignment_goal_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.programMemberId],
foreignColumns: [programMember.id],
name: 'program_goal_assignment_program_member_id_fkey'
}).onDelete('cascade'),
unique('program_goal_assignment_goal_id_program_member_id_unique').on(table.goalId, table.programMemberId),
index('idx_program_goal_assignment_goal_id').on(table.goalId),
index('idx_program_goal_assignment_program_member_id').on(table.programMemberId),
index('idx_program_goal_assignment_due_date').on(table.dueDate)
]
);
// ─── AI Token Usage ──────────────────────────────────────────────────────────
export const aiTokenUsage = pgTable(
@@ -2687,6 +3002,78 @@ export const aiCreditPurchase = pgTable(
]
);
// ─── AI Tutor Fair-Use ───────────────────────────────────────────────────────
export const aiTutorMessageCount = pgTable(
'ai_tutor_message_count',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_tutor_message_count_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
userId: uuid('user_id').notNull(),
/** First day of the calendar month this counter covers. */
periodStart: date('period_start').notNull(),
messageCount: integer('message_count').notNull().default(0),
lastMessageAt: timestamp('last_message_at', { withTimezone: true, mode: 'string' }),
capHitAt: timestamp('cap_hit_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_tutor_message_count_org_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.userId],
foreignColumns: [profile.id],
name: 'ai_tutor_message_count_user_id_fkey'
}).onDelete('cascade'),
unique('ai_tutor_message_count_org_user_period_key').on(table.orgId, table.userId, table.periodStart),
index('idx_ai_tutor_message_count_org_period').on(table.orgId, table.periodStart),
index('idx_ai_tutor_message_count_user').on(table.userId, table.periodStart)
]
);
export const aiTutorCapEvent = pgTable(
'ai_tutor_cap_event',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_tutor_cap_event_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
userId: uuid('user_id').notNull(),
courseId: uuid('course_id'),
/** 'cap_reached' | 'pool_exhausted' | 'tutor_disabled' */
eventType: text('event_type').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_tutor_cap_event_org_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.userId],
foreignColumns: [profile.id],
name: 'ai_tutor_cap_event_user_id_fkey'
}).onDelete('cascade'),
index('idx_ai_tutor_cap_event_org_occurred').on(table.orgId, table.occurredAt),
index('idx_ai_tutor_cap_event_user').on(table.userId, table.occurredAt)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
@@ -2734,3 +3121,192 @@ export const aiChatDocument = pgTable(
index('idx_ai_chat_document_conversation').on(table.conversationId)
]
);
// ─── BullMQ Job State (Postgres source of truth) ─────────────────────────────
// User-visible status for queued/long-running domain runs. BullMQ holds the
// operational job; these tables hold the entity the dashboard polls and
// resumes against. See prd/notification-system and the bullmq plan.
export const jobStatus = pgEnum('JOB_STATUS', ['queued', 'running', 'completed', 'failed', 'canceled']);
/**
* `media_job` — one user-facing media post-processing run (per uploaded asset).
* The BullMQ FlowProducer orchestrates one or more attempts; this row stores
* the latest status the dashboard polls.
*/
export const mediaJob = pgTable(
'media_job',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id').notNull(),
assetId: uuid('asset_id').notNull(),
/** Snapshot of who started this run; never re-derived inside the worker. */
triggeredByProfileId: uuid('triggered_by_profile_id'),
status: jobStatus().default('queued').notNull(),
stage: varchar({ length: 64 }),
progressPercent: integer('progress_percent').default(0),
/** Latest BullMQ root job id from the FlowProducer tree. Operational only. */
rootJobId: text('root_job_id'),
/** All BullMQ job ids for this run, keyed by job name (operational). */
jobIds: jsonb('job_ids').default({}).$type<Record<string, string>>(),
attempt: integer().default(0).notNull(),
maxAttempts: integer('max_attempts').default(3).notNull(),
cancelRequestedAt: timestamp('cancel_requested_at', { withTimezone: true, mode: 'string' }),
/** Provider/cost data once we wire transcription billing. */
costCents: integer('cost_cents').default(0),
result: jsonb().$type<Record<string, unknown> | null>(),
error: jsonb().$type<{ code: string; message: string; details?: unknown } | null>(),
warnings: jsonb().default([]).$type<string[]>(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'media_job_organization_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
foreignKey({
columns: [table.assetId],
foreignColumns: [asset.id],
name: 'media_job_asset_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
foreignKey({
columns: [table.triggeredByProfileId],
foreignColumns: [profile.id],
name: 'media_job_triggered_by_profile_id_fkey'
}).onDelete('set null'),
index('idx_media_job_asset_id').on(table.assetId),
index('idx_media_job_org_status').on(table.organizationId, table.status),
index('idx_media_job_updated_at').on(table.updatedAt)
]
);
/**
* `job_step` — generic checkpoint ledger for any queued domain run. Workers
* look up `(runId, stepKey)` first and short-circuit if the step has a result,
* which makes BullMQ retries safe against partial completion.
*/
export const jobStep = pgTable(
'job_step',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
/** The owning domain (e.g. `media`, `course-import`, `onboarding-bootstrap`). */
domain: varchar({ length: 64 }).notNull(),
/** Domain run id (e.g. `media_job.id`). */
runId: uuid('run_id').notNull(),
/** Stable key for this step (e.g. `probe-metadata`). */
stepKey: varchar('step_key', { length: 96 }).notNull(),
status: jobStatus().default('queued').notNull(),
/** Hash of inputs to detect changed inputs across retries. */
inputHash: text('input_hash'),
/** Provider id (whisper job id, ffmpeg run id, etc.) for de-dup. */
providerId: text('provider_id'),
result: jsonb().$type<Record<string, unknown> | null>(),
error: jsonb().$type<{ code: string; message: string; details?: unknown } | null>(),
attempt: integer().default(0).notNull(),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
unique('job_step_run_step_unique').on(table.domain, table.runId, table.stepKey),
index('idx_job_step_run').on(table.domain, table.runId)
]
);
/**
* Whisper-derived transcript + captions artifact for an uploaded video asset.
* One row per asset (re-transcribe replaces via upsert on asset_id).
*/
export const mediaTranscript = pgTable(
'media_transcript',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id').notNull(),
assetId: uuid('asset_id').notNull(),
mediaJobId: uuid('media_job_id'),
language: varchar({ length: 8 }).notNull(),
provider: varchar({ length: 32 }).default('openai').notNull(),
model: varchar({ length: 64 }).default('whisper-1').notNull(),
text: text().notNull(),
segments: jsonb().$type<{ start: number; end: number; text: string }[]>().notNull(),
vttStorageKey: text('vtt_storage_key').notNull(),
vttBucket: text('vtt_bucket').notNull(),
durationSeconds: integer('duration_seconds'),
costCents: integer('cost_cents').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'media_transcript_organization_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
foreignKey({
columns: [table.assetId],
foreignColumns: [asset.id],
name: 'media_transcript_asset_id_fkey'
})
.onUpdate('cascade')
.onDelete('cascade'),
foreignKey({
columns: [table.mediaJobId],
foreignColumns: [mediaJob.id],
name: 'media_transcript_media_job_id_fkey'
})
.onUpdate('cascade')
.onDelete('set null'),
unique('media_transcript_asset_id_unique').on(table.assetId),
index('idx_media_transcript_asset').on(table.assetId)
]
);
/**
* `dead_letter_job` — terminal failure record after BullMQ exhausts attempts
* or a domain explicitly dead-letters a run. Drives the admin retry/replay UI
* and Tinybird/Slack alerting.
*/
export const deadLetterJob = pgTable(
'dead_letter_job',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id'),
domain: varchar({ length: 64 }).notNull(),
runId: uuid('run_id'),
queueName: varchar('queue_name', { length: 64 }).notNull(),
jobName: varchar('job_name', { length: 96 }).notNull(),
/** Original BullMQ job id. */
bullmqJobId: text('bullmq_job_id'),
payload: jsonb().$type<Record<string, unknown>>(),
error: jsonb().$type<{ code: string; message: string; stack?: string }>(),
attempts: integer().default(0).notNull(),
/** Optional pointer to a replay job once an operator retries. */
replayedAt: timestamp('replayed_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
index('idx_dead_letter_job_domain_created').on(table.domain, table.createdAt),
index('idx_dead_letter_job_org_created').on(table.organizationId, table.createdAt)
]
);