feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
+382 -3
View File
@@ -1,6 +1,7 @@
import {
bigint,
boolean,
date,
doublePrecision,
foreignKey,
index,
@@ -22,13 +23,24 @@ import {
import type { AnswerData } from '@cio/question-types';
import { sql } from 'drizzle-orm';
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS']);
export const courseType = pgEnum('COURSE_TYPE', ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']);
export const locale = pgEnum('LOCALE', ['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
export const plan = pgEnum('PLAN', ['EARLY_ADOPTER', 'ENTERPRISE', 'BASIC']);
export const courseImportSourceType = pgEnum('COURSE_IMPORT_SOURCE_TYPE', ['prompt', 'pdf', 'course']);
export const courseImportDraftStatus = pgEnum('COURSE_IMPORT_DRAFT_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const organizationApiKeyType = pgEnum('ORGANIZATION_API_KEY_TYPE', ['mcp', 'api', 'zapier']);
export const automationUsageCategory = pgEnum('AUTOMATION_USAGE_CATEGORY', ['read', 'write', 'publish']);
export const widgetStatus = pgEnum('WIDGET_STATUS', ['DRAFT', 'PUBLISHED', 'ARCHIVED']);
export const widgetLayoutType = pgEnum('WIDGET_LAYOUT_TYPE', [
'card_grid',
'tag_filter',
'carousel',
'primary_course',
'compact_list',
'editorial_spotlight',
'category_shelf'
]);
export const widgetSelectionMode = pgEnum('WIDGET_SELECTION_MODE', ['manual', 'published']);
export const courseInviteEventType = pgEnum('COURSE_INVITE_EVENT_TYPE', [
'CREATED',
'REVOKED',
@@ -150,7 +162,10 @@ export const analyticsLoginEvents = pgTable(
.primaryKey()
.notNull(),
userId: uuid('user_id').notNull(),
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow()
loggedInAt: timestamp('logged_in_at', { withTimezone: true, mode: 'string' }).defaultNow(),
loggedInDate: date('logged_in_date')
.default(sql`CURRENT_DATE`)
.notNull()
},
(table) => [
index('idx_analytics_login_events_logged_in_at').using(
@@ -158,12 +173,16 @@ export const analyticsLoginEvents = pgTable(
table.loggedInAt.asc().nullsLast().op('timestamptz_ops')
),
index('idx_analytics_login_events_user_id').using('btree', table.userId.asc().nullsLast().op('uuid_ops')),
index('idx_analytics_login_events_logged_in_date').using(
'btree',
table.loggedInDate.asc().nullsLast().op('date_ops')
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'analytics_login_events_user_id_fkey'
}),
unique('analytics_login_events_user_id_unique').on(table.userId)
unique('analytics_login_events_user_id_date_unique').on(table.userId, table.loggedInDate)
]
);
@@ -596,6 +615,15 @@ export const course = pgTable(
exerciseMinScorePercent?: number | null;
emailMessage?: string | null;
}>(),
compliance: jsonb().$type<{
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
}>(),
status: text().default('ACTIVE').notNull(),
type: courseType().default('LIVE_CLASS')
},
@@ -610,6 +638,127 @@ export const course = pgTable(
]
);
export const courseCompletionRecord = pgTable(
'course_completion_record',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
groupMemberId: uuid('group_member_id').notNull(),
profileId: uuid('profile_id').notNull(),
cycleNumber: integer('cycle_number').default(1).notNull(),
status: varchar().default('not_started').notNull(),
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }).notNull(),
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
validUntil: timestamp('valid_until', { withTimezone: true, mode: 'string' }),
expiredAt: timestamp('expired_at', { withTimezone: true, mode: 'string' }),
score: integer(),
attempts: integer().default(0),
timeSpentMinutes: integer('time_spent_minutes').default(0),
waivedBy: uuid('waived_by'),
waiverReason: text('waiver_reason'),
waiverExpiresAt: timestamp('waiver_expires_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.courseId],
foreignColumns: [course.id],
name: 'course_completion_record_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.groupMemberId],
foreignColumns: [groupmember.id],
name: 'course_completion_record_group_member_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_completion_record_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.waivedBy],
foreignColumns: [profile.id],
name: 'course_completion_record_waived_by_fkey'
}).onDelete('set null'),
unique('course_completion_record_course_profile_cycle_key').on(table.courseId, table.profileId, table.cycleNumber),
index('idx_course_completion_record_course_id').on(table.courseId),
index('idx_course_completion_record_profile_id').on(table.profileId),
index('idx_course_completion_record_status').on(table.status),
index('idx_course_completion_record_due_date').on(table.dueDate),
index('idx_course_completion_record_valid_until').on(table.validUntil)
]
);
export const courseCompletionNotificationEvent = pgTable(
'course_completion_notification_event',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
channel: varchar().notNull(),
eventType: varchar('event_type').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_completion_notification_event_record_id_fkey'
}).onDelete('cascade'),
unique('course_completion_notification_event_record_channel_type_key').on(
table.courseCompletionRecordId,
table.channel,
table.eventType
)
]
);
export const courseCertificateIssue = pgTable(
'course_certificate_issue',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
courseId: uuid('course_id').notNull(),
profileId: uuid('profile_id').notNull(),
courseCompletionRecordId: uuid('course_completion_record_id').notNull(),
cycleNumber: integer('cycle_number').notNull(),
issuedAt: timestamp('issued_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }).notNull(),
status: varchar().default('valid').notNull(),
fileUrl: text('file_url')
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'course_certificate_issue_course_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.profileId],
foreignColumns: [profile.id],
name: 'course_certificate_issue_profile_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseCompletionRecordId],
foreignColumns: [courseCompletionRecord.id],
name: 'course_certificate_issue_record_id_fkey'
}).onDelete('cascade'),
unique('course_certificate_issue_record_id_key').on(table.courseCompletionRecordId),
index('idx_course_certificate_issue_course_id').on(table.courseId),
index('idx_course_certificate_issue_profile_id').on(table.profileId),
index('idx_course_certificate_issue_status').on(table.status)
]
);
export const appsPoll = pgTable(
'apps_poll',
{
@@ -1859,6 +2008,132 @@ export const tagAssignment = pgTable(
]
);
export const widget = pgTable(
'widget',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
organizationId: uuid('organization_id').notNull(),
name: varchar({ length: 120 }).notNull(),
status: widgetStatus('status').default('DRAFT').notNull(),
layoutType: widgetLayoutType('layout_type').default('card_grid').notNull(),
selectionMode: widgetSelectionMode('selection_mode').default('manual').notNull(),
publicKey: varchar('public_key', { length: 64 }).notNull(),
config: jsonb('config')
.default(sql`'{}'::jsonb`)
.notNull(),
hasUnpublishedChanges: boolean('has_unpublished_changes').default(false).notNull(),
latestPublishedVersionId: uuid('latest_published_version_id'),
createdByUserId: uuid('created_by_user_id').notNull(),
updatedByUserId: uuid('updated_by_user_id'),
deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.organizationId],
foreignColumns: [organization.id],
name: 'widget_organization_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.createdByUserId],
foreignColumns: [user.id],
name: 'widget_created_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.updatedByUserId],
foreignColumns: [user.id],
name: 'widget_updated_by_user_id_fkey'
}).onDelete('set null'),
index('idx_widget_organization_id').on(table.organizationId),
index('idx_widget_status').on(table.status),
unique('widget_public_key_key').on(table.publicKey)
]
);
export const widgetCourse = pgTable(
'widget_course',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
courseId: uuid('course_id').notNull(),
order: integer().default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_course_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'widget_course_course_id_fkey'
}).onDelete('cascade'),
index('idx_widget_course_widget_id').on(table.widgetId),
index('idx_widget_course_course_id').on(table.courseId),
unique('widget_course_widget_course_key').on(table.widgetId, table.courseId)
]
);
export const widgetVersion = pgTable(
'widget_version',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey()
.notNull(),
widgetId: uuid('widget_id').notNull(),
version: integer().notNull(),
configSnapshot: jsonb('config_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
payloadSnapshot: jsonb('payload_snapshot')
.default(sql`'{}'::jsonb`)
.notNull(),
runtimeManifest: jsonb('runtime_manifest')
.default(sql`'{}'::jsonb`)
.notNull(),
rolledBackFromVersionId: uuid('rolled_back_from_version_id'),
publishedByUserId: uuid('published_by_user_id').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true, mode: 'string' })
.default(sql`timezone('utc'::text, now())`)
.notNull()
},
(table) => [
foreignKey({
columns: [table.widgetId],
foreignColumns: [widget.id],
name: 'widget_version_widget_id_fkey'
}).onDelete('cascade'),
foreignKey({
columns: [table.publishedByUserId],
foreignColumns: [user.id],
name: 'widget_version_published_by_user_id_fkey'
}).onDelete('restrict'),
foreignKey({
columns: [table.rolledBackFromVersionId],
foreignColumns: [table.id],
name: 'widget_version_rolled_back_from_version_id_fkey'
}).onDelete('set null'),
index('idx_widget_version_widget_id').on(table.widgetId),
unique('widget_version_widget_version_key').on(table.widgetId, table.version)
]
);
export const dashOrgStats = pgView('dash_org_stats', {
orgId: uuid('org_id'),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
@@ -2273,3 +2548,107 @@ export const programNewsfeedComment = pgTable(
}).onDelete('cascade')
]
);
// ─── AI Token Usage ──────────────────────────────────────────────────────────
export const aiTokenUsage = pgTable(
'ai_token_usage',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_token_usage_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
userId: uuid('user_id').notNull(),
courseId: uuid('course_id').notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
model: text().notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_token_usage_org_id_fkey'
}),
index('idx_ai_token_usage_org_month').on(table.orgId, table.createdAt)
]
);
// ─── AI Credit Balance ───────────────────────────────────────────────────────
export const aiCreditBalance = pgTable(
'ai_credit_balance',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_credit_balance_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
cache: 1
}),
orgId: uuid('org_id').notNull(),
balance: integer().notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.orgId],
foreignColumns: [organization.id],
name: 'ai_credit_balance_org_id_fkey'
}),
unique('ai_credit_balance_org_id_key').on(table.orgId)
]
);
export const aiChatConversation = pgTable(
'ai_chat_conversation',
{
id: uuid()
.default(sql`gen_random_uuid()`)
.primaryKey(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
title: text().default('New conversation'),
messages: jsonb().notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.courseId],
foreignColumns: [course.id],
name: 'ai_chat_conversation_course_id_fkey'
}),
index('idx_ai_chat_conversation_course_user').on(table.courseId, table.userId)
]
);
export const aiChatDocument = pgTable(
'ai_chat_document',
{
id: text().primaryKey(),
conversationId: uuid('conversation_id').notNull(),
courseId: uuid('course_id').notNull(),
userId: uuid('user_id').notNull(),
assetId: uuid('asset_id'),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
text: text().notNull(),
wordCount: integer('word_count').notNull().default(0),
pageCount: integer('page_count'),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.conversationId],
foreignColumns: [aiChatConversation.id],
name: 'ai_chat_document_conversation_id_fkey'
}).onDelete('cascade'),
index('idx_ai_chat_document_conversation').on(table.conversationId)
]
);