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
@@ -0,0 +1,96 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
/** Cap on how many documents we keep per conversation. Older ones get pruned on insert. */
const MAX_DOCUMENTS_PER_CONVERSATION = 10;
export interface ChatDocumentRecord {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
createdAt: string;
}
export async function createChatDocument(record: {
id: string;
conversationId: string;
courseId: string;
userId: string;
assetId: string | null;
fileName: string;
mimeType: string;
text: string;
wordCount: number;
pageCount: number | null;
}): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.insert(schema.aiChatDocument).values(record);
const all = await tx
.select({ id: schema.aiChatDocument.id })
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.conversationId, record.conversationId))
.orderBy(desc(schema.aiChatDocument.createdAt));
if (all.length > MAX_DOCUMENTS_PER_CONVERSATION) {
const toDelete = all.slice(MAX_DOCUMENTS_PER_CONVERSATION).map((row) => row.id);
await tx.delete(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, toDelete));
}
});
} catch (error) {
console.error('createChatDocument error:', error);
throw new Error('Failed to persist chat document');
}
}
export async function getChatDocument(documentId: string): Promise<ChatDocumentRecord | null> {
try {
const [row] = await db
.select()
.from(schema.aiChatDocument)
.where(eq(schema.aiChatDocument.id, documentId))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatDocument error:', error);
throw new Error('Failed to fetch chat document');
}
}
export async function getChatDocumentsByIds(documentIds: string[]): Promise<ChatDocumentRecord[]> {
if (documentIds.length === 0) return [];
try {
return await db.select().from(schema.aiChatDocument).where(inArray(schema.aiChatDocument.id, documentIds));
} catch (error) {
console.error('getChatDocumentsByIds error:', error);
throw new Error('Failed to fetch chat documents');
}
}
export async function listChatDocumentsByConversation(
conversationId: string,
userId: string
): Promise<ChatDocumentRecord[]> {
try {
return await db
.select()
.from(schema.aiChatDocument)
.where(and(eq(schema.aiChatDocument.conversationId, conversationId), eq(schema.aiChatDocument.userId, userId)))
.orderBy(desc(schema.aiChatDocument.createdAt));
} catch (error) {
console.error('listChatDocumentsByConversation error:', error);
throw new Error('Failed to list chat documents');
}
}
@@ -0,0 +1,158 @@
import { and, eq, desc } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
const MAX_CONVERSATIONS_PER_COURSE = 50;
export interface ChatConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
}
// ─── List all conversations for a user in a course ───────────────────────────
export async function listChatConversations(courseId: string, userId: string): Promise<ChatConversationSummary[]> {
try {
const rows = await db
.select({
id: schema.aiChatConversation.id,
title: schema.aiChatConversation.title,
createdAt: schema.aiChatConversation.createdAt,
updatedAt: schema.aiChatConversation.updatedAt
})
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.courseId, courseId), eq(schema.aiChatConversation.userId, userId)))
.orderBy(desc(schema.aiChatConversation.updatedAt))
.limit(MAX_CONVERSATIONS_PER_COURSE);
return rows;
} catch (error) {
console.error('listChatConversations error:', error);
throw new Error('Failed to list chat conversations');
}
}
// ─── Get a single conversation with messages ─────────────────────────────────
export async function getChatConversation(conversationId: string, userId: string) {
try {
const [row] = await db
.select()
.from(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getChatConversation error:', error);
throw new Error('Failed to fetch chat conversation');
}
}
// ─── Create a new conversation ───────────────────────────────────────────────
export async function createChatConversation(
courseId: string,
userId: string,
title?: string
): Promise<{ id: string; title: string | null }> {
try {
const [row] = await db
.insert(schema.aiChatConversation)
.values({
courseId,
userId,
title: title || 'New conversation',
messages: []
})
.returning({ id: schema.aiChatConversation.id, title: schema.aiChatConversation.title });
return row;
} catch (error) {
console.error('createChatConversation error:', error);
throw new Error('Failed to create chat conversation');
}
}
// ─── Save messages to a conversation ─────────────────────────────────────────
export async function saveChatMessages(conversationId: string, userId: string, messages: unknown[], title?: string) {
try {
const updateData: Record<string, unknown> = {
messages,
updatedAt: new Date().toISOString()
};
if (title) {
updateData.title = title;
}
await db
.update(schema.aiChatConversation)
.set(updateData)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('saveChatMessages error:', error);
throw new Error('Failed to save chat messages');
}
}
// ─── Delete a conversation ───────────────────────────────────────────────────
export async function deleteChatConversation(conversationId: string, userId: string) {
try {
await db
.delete(schema.aiChatConversation)
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('deleteChatConversation error:', error);
throw new Error('Failed to delete chat conversation');
}
}
// ─── Update conversation title ───────────────────────────────────────────────
export async function updateConversationTitle(conversationId: string, userId: string, title: string): Promise<void> {
try {
await db
.update(schema.aiChatConversation)
.set({ title, updatedAt: new Date().toISOString() })
.where(and(eq(schema.aiChatConversation.id, conversationId), eq(schema.aiChatConversation.userId, userId)));
} catch (error) {
console.error('updateConversationTitle error:', error);
throw new Error('Failed to update conversation title');
}
}
// ─── Legacy compat (keep old exports working during migration) ───────────────
export async function getChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length === 0) return [];
const latestConversation = await getChatConversation(conversations[0].id, userId);
return (latestConversation?.messages ?? []) as unknown[];
}
export async function saveChatHistory(courseId: string, userId: string, messages: unknown[]) {
const conversations = await listChatConversations(courseId, userId);
if (conversations.length > 0) {
await saveChatMessages(conversations[0].id, userId, messages);
} else {
const newConversation = await createChatConversation(courseId, userId);
await saveChatMessages(newConversation.id, userId, messages);
}
}
export async function deleteChatHistory(courseId: string, userId: string) {
const conversations = await listChatConversations(courseId, userId);
for (const conversation of conversations) {
await deleteChatConversation(conversation.id, userId);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './chat-history';
export * from './chat-document';
@@ -0,0 +1,66 @@
import * as schema from '@db/schema';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export interface TSearchOrgAudienceMember {
memberId: number;
profileId: string | null;
name: string;
email: string;
avatarUrl: string | null;
createdAt: string;
}
export async function searchOrgAudience(
orgId: string,
search: string,
limit: number
): Promise<TSearchOrgAudienceMember[]> {
try {
const searchValue = `%${search.trim()}%`;
const audienceNameSql = sql<string>`COALESCE(NULLIF(${schema.profile.fullname}, ''), ${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceEmailSql = sql<string>`COALESCE(${schema.profile.email}, ${schema.organizationmember.email})`;
const audienceCreatedAtSql = sql<string>`COALESCE(${schema.profile.createdAt}, ${schema.organizationmember.createdAt})`;
const rows = await db
.select({
memberId: schema.organizationmember.id,
profileId: schema.profile.id,
name: audienceNameSql.as('name'),
email: audienceEmailSql.as('email'),
avatarUrl: schema.profile.avatarUrl,
createdAt: audienceCreatedAtSql.as('createdAt')
})
.from(schema.organizationmember)
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
.where(
and(
eq(schema.organizationmember.organizationId, orgId),
eq(schema.organizationmember.roleId, ROLE.STUDENT),
or(
ilike(schema.profile.fullname, searchValue),
ilike(schema.profile.email, searchValue),
ilike(schema.organizationmember.email, searchValue)
)
)
)
.orderBy(desc(audienceCreatedAtSql), desc(schema.organizationmember.id))
.limit(limit);
return rows.map((row) => {
const email = row.email?.trim() ?? '';
const name = row.name?.trim() || (email.includes('@') ? email.split('@')[0] : email) || '';
return {
...row,
name,
email
};
});
} catch (error) {
console.error('searchOrgAudience error:', error);
throw new Error(`Failed to search org audience: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -0,0 +1 @@
export * from './audience';
@@ -0,0 +1,474 @@
import * as schema from '@db/schema';
import { ROLE } from '@cio/utils/constants';
import type {
TCourse,
TCourseCertificateIssue,
TCourseCompletionNotificationEvent,
TCourseCompletionRecord,
TGroupmember,
TNewCourseCertificateIssue,
TNewCourseCompletionNotificationEvent,
TNewCourseCompletionRecord
} from '@db/types';
import { and, asc, desc, eq, inArray, isNotNull, lte, or, sql } from 'drizzle-orm';
import { db, type DbOrTxClient } from '@db/drizzle';
type CourseMemberProfile = {
id: string;
fullname: string | null;
username: string | null;
avatarUrl: string | null;
email: string | null;
};
export type CourseComplianceCurrentRow = {
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord | null;
};
export type CourseComplianceReminderRow = {
course: TCourse;
member: TGroupmember;
profile: CourseMemberProfile | null;
record: TCourseCompletionRecord;
};
export async function getStudentCourseMembersForCompliance(
courseId: string,
profileIds?: string[]
): Promise<Array<{ member: TGroupmember; profile: CourseMemberProfile | null }>> {
try {
const conditions = [
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
];
if (profileIds && profileIds.length > 0) {
conditions.push(inArray(schema.groupmember.profileId, profileIds));
}
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(and(...conditions))
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null
}));
} catch (error) {
console.error('getStudentCourseMembersForCompliance error:', error);
throw new Error(
`Failed to get student course members for compliance: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseCurrentComplianceRows(courseId: string): Promise<CourseComplianceCurrentRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.courseId, courseId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.leftJoin(
latestCycles,
and(eq(latestCycles.courseId, schema.course.id), eq(latestCycles.profileId, schema.groupmember.profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, schema.groupmember.profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestCycles.latestCycleNumber)
)
)
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
isNotNull(schema.groupmember.profileId)
)
)
.orderBy(asc(schema.profile.fullname), asc(schema.groupmember.createdAt));
return result.map((row) => ({
member: row.member,
profile: row.profile ?? null,
record: row.record ?? null
}));
} catch (error) {
console.error('getCourseCurrentComplianceRows error:', error);
throw new Error(
`Failed to get course current compliance rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getCourseComplianceHistoryRows(
courseId: string,
profileId: string
): Promise<{
member: TGroupmember | null;
profile: CourseMemberProfile | null;
records: TCourseCompletionRecord[];
}> {
try {
const [memberRow, records] = await Promise.all([
db
.select({
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
}
})
.from(schema.groupmember)
.innerJoin(schema.course, eq(schema.course.groupId, schema.groupmember.groupId))
.leftJoin(schema.profile, eq(schema.groupmember.profileId, schema.profile.id))
.where(
and(
eq(schema.course.id, courseId),
eq(schema.groupmember.roleId, ROLE.STUDENT),
eq(schema.groupmember.profileId, profileId)
)
)
.limit(1),
db
.select()
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
eq(schema.courseCompletionRecord.profileId, profileId)
)
)
.orderBy(desc(schema.courseCompletionRecord.cycleNumber))
]);
return {
member: memberRow[0]?.member ?? null,
profile: memberRow[0]?.profile ?? null,
records
};
} catch (error) {
console.error('getCourseComplianceHistoryRows error:', error);
throw new Error(
`Failed to get course compliance history rows: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function getLatestComplianceRecordsByProfiles(
courseId: string,
profileIds: string[]
): Promise<TCourseCompletionRecord[]> {
try {
if (profileIds.length === 0) {
return [];
}
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(
and(
eq(schema.courseCompletionRecord.courseId, courseId),
inArray(schema.courseCompletionRecord.profileId, profileIds)
)
)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles_by_profile');
return await db
.select({
id: schema.courseCompletionRecord.id,
courseId: schema.courseCompletionRecord.courseId,
groupMemberId: schema.courseCompletionRecord.groupMemberId,
profileId: schema.courseCompletionRecord.profileId,
cycleNumber: schema.courseCompletionRecord.cycleNumber,
status: schema.courseCompletionRecord.status,
dueDate: schema.courseCompletionRecord.dueDate,
startedAt: schema.courseCompletionRecord.startedAt,
completedAt: schema.courseCompletionRecord.completedAt,
validUntil: schema.courseCompletionRecord.validUntil,
expiredAt: schema.courseCompletionRecord.expiredAt,
score: schema.courseCompletionRecord.score,
attempts: schema.courseCompletionRecord.attempts,
timeSpentMinutes: schema.courseCompletionRecord.timeSpentMinutes,
waivedBy: schema.courseCompletionRecord.waivedBy,
waiverReason: schema.courseCompletionRecord.waiverReason,
waiverExpiresAt: schema.courseCompletionRecord.waiverExpiresAt,
createdAt: schema.courseCompletionRecord.createdAt,
updatedAt: schema.courseCompletionRecord.updatedAt
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
);
} catch (error) {
console.error('getLatestComplianceRecordsByProfiles error:', error);
throw new Error(
`Failed to get latest compliance records by profiles: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionRecord(
values: TNewCourseCompletionRecord,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord> {
try {
const [created] = await dbClient.insert(schema.courseCompletionRecord).values(values).returning();
if (!created) {
throw new Error('Failed to create course completion record');
}
return created;
} catch (error) {
console.error('createCourseCompletionRecord error:', error);
throw new Error(
`Failed to create course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCompletionRecord(
recordId: string,
values: Partial<TNewCourseCompletionRecord>,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionRecord | null> {
try {
const [updated] = await dbClient
.update(schema.courseCompletionRecord)
.set({
...values,
updatedAt: new Date().toISOString()
})
.where(eq(schema.courseCompletionRecord.id, recordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCompletionRecord error:', error);
throw new Error(
`Failed to update course completion record: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCompletionNotificationEvent(
values: TNewCourseCompletionNotificationEvent,
dbClient: DbOrTxClient = db
): Promise<TCourseCompletionNotificationEvent | null> {
try {
const [created] = await dbClient
.insert(schema.courseCompletionNotificationEvent)
.values(values)
.onConflictDoNothing({
target: [
schema.courseCompletionNotificationEvent.courseCompletionRecordId,
schema.courseCompletionNotificationEvent.channel,
schema.courseCompletionNotificationEvent.eventType
]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCompletionNotificationEvent error:', error);
throw new Error(
`Failed to create course completion notification event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function createCourseCertificateIssue(
values: TNewCourseCertificateIssue,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [created] = await dbClient
.insert(schema.courseCertificateIssue)
.values(values)
.onConflictDoNothing({
target: [schema.courseCertificateIssue.courseCompletionRecordId]
})
.returning();
return created ?? null;
} catch (error) {
console.error('createCourseCertificateIssue error:', error);
throw new Error(
`Failed to create course certificate issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateCourseCertificateIssueStatusByRecordId(
courseCompletionRecordId: string,
status: string,
dbClient: DbOrTxClient = db
): Promise<TCourseCertificateIssue | null> {
try {
const [updated] = await dbClient
.update(schema.courseCertificateIssue)
.set({
status
})
.where(eq(schema.courseCertificateIssue.courseCompletionRecordId, courseCompletionRecordId))
.returning();
return updated ?? null;
} catch (error) {
console.error('updateCourseCertificateIssueStatusByRecordId error:', error);
throw new Error(
`Failed to update course certificate issue status by record ID: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
export async function listComplianceRecordsReadyForExpiry(nowIso: string): Promise<TCourseCompletionRecord[]> {
try {
return await db
.select({
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
isNotNull(schema.courseCompletionRecord.completedAt),
isNotNull(schema.courseCompletionRecord.validUntil),
lte(schema.courseCompletionRecord.validUntil, nowIso),
sql`${schema.courseCompletionRecord.expiredAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.validUntil))
.then((rows) => rows.map((row) => row.record));
} catch (error) {
console.error('listComplianceRecordsReadyForExpiry error:', error);
throw new Error(
`Failed to list compliance records ready for expiry: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function listLatestComplianceRecordsForReminderScan(): Promise<CourseComplianceReminderRow[]> {
try {
const latestCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`max(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_course_compliance_cycles');
const result = await db
.select({
course: schema.course,
member: schema.groupmember,
profile: {
id: schema.profile.id,
fullname: schema.profile.fullname,
username: schema.profile.username,
avatarUrl: schema.profile.avatarUrl,
email: schema.profile.email
},
record: schema.courseCompletionRecord
})
.from(schema.courseCompletionRecord)
.innerJoin(
latestCycles,
and(
eq(latestCycles.courseId, schema.courseCompletionRecord.courseId),
eq(latestCycles.profileId, schema.courseCompletionRecord.profileId),
eq(latestCycles.latestCycleNumber, schema.courseCompletionRecord.cycleNumber)
)
)
.innerJoin(schema.course, eq(schema.course.id, schema.courseCompletionRecord.courseId))
.innerJoin(schema.groupmember, eq(schema.groupmember.id, schema.courseCompletionRecord.groupMemberId))
.leftJoin(schema.profile, eq(schema.profile.id, schema.courseCompletionRecord.profileId))
.where(
and(
eq(schema.course.type, 'COMPLIANCE'),
or(
eq(schema.courseCompletionRecord.status, 'not_started'),
eq(schema.courseCompletionRecord.status, 'in_progress'),
eq(schema.courseCompletionRecord.status, 'expiring_soon'),
eq(schema.courseCompletionRecord.status, 'in_grace_period'),
eq(schema.courseCompletionRecord.status, 'non_compliant')
),
sql`${schema.courseCompletionRecord.completedAt} IS NULL`
)
)
.orderBy(asc(schema.courseCompletionRecord.dueDate));
return result.map((row) => ({
course: row.course,
member: row.member,
profile: row.profile ?? null,
record: row.record
}));
} catch (error) {
console.error('listLatestComplianceRecordsForReminderScan error:', error);
throw new Error(
`Failed to list latest compliance records for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
+144 -5
View File
@@ -42,6 +42,10 @@ export interface TAdminCourse extends TBaseCourse {
export interface TStudentCourse extends TBaseCourse {
progressRate: number;
exercisesCompleted: number;
complianceStatus: string | null;
complianceCycleNumber: number | null;
complianceDueDate: string | null;
complianceValidUntil: string | null;
}
/**
@@ -257,8 +261,7 @@ export const getCoursesBySiteNameForSetup = async (siteName: string) => {
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.innerJoin(schema.organization, eq(schema.group.organizationId, schema.organization.id))
.where(eq(schema.organization.siteName, siteName))
.limit(1);
.where(eq(schema.organization.siteName, siteName));
return result.map((row) => ({
...row.course
@@ -592,6 +595,16 @@ export async function getCourseProgress(
/** Course + org fields needed for certification email and threshold rules */
export type TCourseCertificationRow = {
type: string | null;
compliance: {
retakeIntervalMonths: number;
gracePeriodDays?: number;
reminderDaysBefore?: number[];
isMandatory?: boolean;
framework?: 'HIPAA' | 'OSHA' | 'SOX' | 'GDPR' | 'PCI_DSS' | 'FERPA' | 'ISO' | 'CUSTOM' | null;
maxRetakeAttempts?: number | null;
passingScore?: number;
} | null;
certificate: {
isDownloadable?: boolean;
theme?: string;
@@ -610,6 +623,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
try {
const [row] = await db
.select({
type: schema.course.type,
compliance: schema.course.compliance,
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
@@ -761,6 +776,99 @@ export const getOrgCourses = async ({
}
};
export interface TSearchOrgCourse {
id: string;
title: string;
slug: string | null;
description: string;
updatedAt: string | null;
}
export async function searchOrgCourses(orgId: string, search: string, limit: number): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgCourses error:', error);
throw new Error(`Failed to search org courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsCourses(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgCourse[]> {
try {
const searchValue = `%${search.trim()}%`;
const result = await db
.select({
id: schema.course.id,
title: schema.course.title,
slug: schema.course.slug,
description: schema.course.description,
updatedAt: schema.course.updatedAt
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
and(
eq(schema.programCourse.programId, schema.program.id),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE')
)
)
.leftJoin(
schema.programMember,
and(eq(schema.program.id, schema.programMember.programId), eq(schema.programMember.profileId, profileId))
)
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id)),
or(ilike(schema.course.title, searchValue), ilike(schema.course.description, searchValue))
)
)
.groupBy(schema.course.id)
.orderBy(desc(schema.course.updatedAt))
.limit(limit);
return result;
} catch (error) {
console.error('searchLmsCourses error:', error);
throw new Error(`Failed to search LMS courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
interface GetEnrolledCoursesOptions {
/** Organization ID (required) */
orgId: string;
@@ -780,6 +888,17 @@ export const getEnrolledCourses = async ({
profileId
}: GetEnrolledCoursesOptions): Promise<TStudentCourse[]> => {
try {
const latestComplianceCycles = db
.select({
courseId: schema.courseCompletionRecord.courseId,
profileId: schema.courseCompletionRecord.profileId,
latestCycleNumber: sql<number>`MAX(${schema.courseCompletionRecord.cycleNumber})`.as('latest_cycle_number')
})
.from(schema.courseCompletionRecord)
.where(eq(schema.courseCompletionRecord.profileId, profileId))
.groupBy(schema.courseCompletionRecord.courseId, schema.courseCompletionRecord.profileId)
.as('latest_enrolled_course_compliance_cycles');
// Single query covering both access paths:
// 1. Direct enrollment: student has a groupmember row for the course's group
// 2. Program membership: student is a program_member of a program that contains the course
@@ -817,7 +936,11 @@ export const getEnrolledCourses = async ({
or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id)),
sql`gm.profile_id = ${sql.raw(`'${profileId}'::uuid`)}`
)}
)`.as('exercises_completed')
)`.as('exercises_completed'),
complianceStatus: schema.courseCompletionRecord.status,
complianceCycleNumber: schema.courseCompletionRecord.cycleNumber,
complianceDueDate: schema.courseCompletionRecord.dueDate,
complianceValidUntil: schema.courseCompletionRecord.validUntil
})
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
@@ -825,6 +948,18 @@ export const getEnrolledCourses = async ({
schema.groupmember,
and(eq(schema.group.id, schema.groupmember.groupId), eq(schema.groupmember.profileId, profileId))
)
.leftJoin(
latestComplianceCycles,
and(eq(latestComplianceCycles.courseId, schema.course.id), eq(latestComplianceCycles.profileId, profileId))
)
.leftJoin(
schema.courseCompletionRecord,
and(
eq(schema.courseCompletionRecord.courseId, schema.course.id),
eq(schema.courseCompletionRecord.profileId, profileId),
eq(schema.courseCompletionRecord.cycleNumber, latestComplianceCycles.latestCycleNumber)
)
)
.leftJoin(schema.programCourse, eq(schema.course.id, schema.programCourse.courseId))
.leftJoin(
schema.program,
@@ -847,7 +982,7 @@ export const getEnrolledCourses = async ({
or(isNotNull(schema.groupmember.id), isNotNull(schema.programMember.id))
)
)
.groupBy(schema.course.id)
.groupBy(schema.course.id, schema.courseCompletionRecord.id)
.orderBy(desc(schema.course.createdAt));
return result.map((row) => ({
@@ -855,7 +990,11 @@ export const getEnrolledCourses = async ({
lessonCount: Number(row.lessonCount),
progressRate: Number(row.progressRate),
exerciseCount: Number(row.exerciseCount),
exercisesCompleted: Number(row.exercisesCompleted)
exercisesCompleted: Number(row.exercisesCompleted),
complianceStatus: row.complianceStatus ?? null,
complianceCycleNumber: row.complianceCycleNumber ?? null,
complianceDueDate: row.complianceDueDate ?? null,
complianceValidUntil: row.complianceValidUntil ?? null
}));
} catch (error) {
console.error('getEnrolledCourses error:', error);
+1
View File
@@ -1,5 +1,6 @@
export * from './course';
export * from './course';
export * from './compliance';
export * from './content';
export * from './people';
export * from './invite';
+64
View File
@@ -1,19 +1,24 @@
import {
analyticsLoginEvents,
and,
course,
dashOrgStats,
db,
desc,
eq,
gte,
group,
groupmember,
isNotNull,
lesson,
lessonCompletion,
organizationmember,
profile,
sql
} from '@db/drizzle';
import { ROLE } from '@cio/utils/constants';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
@@ -135,3 +140,62 @@ export function getRecentCertifications(orgId: string) {
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
/**
* Returns login counts grouped by day-of-week (0 = Sunday … 6 = Saturday) for
* students of the given organization over the last `days` days.
*
* Each row in analytics_login_events is already one-per-user-per-day, so the
* counts represent distinct active students per weekday — the chart shows
* "most active day," not raw login volume.
*/
export function getOrgStudentLoginsByDayOfWeek(orgId: string, days: number) {
const since = sql`CURRENT_DATE - ${sql.raw(`${days}`)}::int`;
return db
.select({
dayOfWeek: sql<number>`EXTRACT(DOW FROM ${analyticsLoginEvents.loggedInDate})::int`.as('day_of_week'),
count: sql<number>`COUNT(*)::int`.as('count')
})
.from(analyticsLoginEvents)
.innerJoin(organizationmember, eq(organizationmember.profileId, analyticsLoginEvents.userId))
.where(
and(
eq(organizationmember.organizationId, orgId),
eq(organizationmember.roleId, ROLE.STUDENT),
gte(analyticsLoginEvents.loggedInDate, since)
)
)
.groupBy(sql`day_of_week`)
.orderBy(sql`day_of_week`);
}
function getDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function getUserLoginStreak(userId: string) {
const rows = await db
.select({
loggedInDate: analyticsLoginEvents.loggedInDate
})
.from(analyticsLoginEvents)
.where(eq(analyticsLoginEvents.userId, userId))
.orderBy(desc(analyticsLoginEvents.loggedInDate))
.limit(90);
const loggedInDates = new Set(rows.map((row) => row.loggedInDate));
const cursorDate = new Date();
if (!loggedInDates.has(getDateKey(cursorDate))) {
cursorDate.setDate(cursorDate.getDate() - 1);
}
let daysActive = 0;
while (loggedInDates.has(getDateKey(cursorDate))) {
daysActive += 1;
cursorDate.setDate(cursorDate.getDate() - 1);
}
return { daysActive };
}
@@ -198,6 +198,7 @@ export async function updateUngroupedExercisesSectionId(
export async function deleteExercise(exerciseId: string, dbClient: DbOrTxClient = db) {
try {
const [deleted] = await dbClient.delete(schema.exercise).where(eq(schema.exercise.id, exerciseId)).returning();
console.log('Deleted exercise:', deleted);
return deleted || null;
} catch (error) {
console.error('deleteExercise error:', error);
+2
View File
@@ -1,6 +1,7 @@
export * from './organization';
export * from './auth';
export * from './attendance';
export * from './audience';
export * from './assets';
export * from './course';
export * from './group';
@@ -13,3 +14,4 @@ export * from './submission';
export * from './tag';
export * from './course-import';
export * from './program';
export * from './widget';
@@ -367,6 +367,33 @@ export const getUserOrgRole = async (orgId: string, profileId: string): Promise<
return result.length > 0 ? Number(result[0].roleId) : null;
};
/**
* Gets all org memberships for a user as { [orgId]: roleId }.
* Used to attach org roles to the Better Auth session so middleware can
* read membership/role from the session cookie cache instead of hitting the DB.
*/
export const getUserOrgRolesMap = async (profileId: string): Promise<Record<string, number>> => {
try {
const rows = await db
.select({
organizationId: schema.organizationmember.organizationId,
roleId: schema.organizationmember.roleId
})
.from(schema.organizationmember)
.where(eq(schema.organizationmember.profileId, profileId));
const map: Record<string, number> = {};
for (const row of rows) {
map[row.organizationId] = Number(row.roleId);
}
return map;
} catch (error) {
console.error('getUserOrgRolesMap error:', error);
throw new Error('Failed to fetch user org roles map');
}
};
/**
* Checks if a user is a team member (ADMIN or TUTOR) of an organization
* @param orgId Organization ID
+72 -1
View File
@@ -1,6 +1,6 @@
import * as schema from '@db/schema';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
@@ -118,6 +118,77 @@ export async function getProgramsByOrg(
}
}
export interface TSearchOrgProgram {
id: string;
name: string;
description: string | null;
status: string;
updatedAt: string | null;
}
export async function searchOrgPrograms(orgId: string, search: string, limit: number): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.program)
.where(
and(
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchOrgPrograms error:', error);
throw new Error(`Failed to search org programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function searchLmsPrograms(
orgId: string,
profileId: string,
search: string,
limit: number
): Promise<TSearchOrgProgram[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select({
id: schema.program.id,
name: schema.program.name,
description: schema.program.description,
status: schema.program.status,
updatedAt: schema.program.updatedAt
})
.from(schema.programMember)
.innerJoin(schema.program, eq(schema.programMember.programId, schema.program.id))
.where(
and(
eq(schema.programMember.profileId, profileId),
eq(schema.program.organizationId, orgId),
eq(schema.program.status, 'ACTIVE'),
or(ilike(schema.program.name, searchValue), ilike(schema.program.description, searchValue))
)
)
.orderBy(desc(schema.program.updatedAt))
.limit(limit);
} catch (error) {
console.error('searchLmsPrograms error:', error);
throw new Error(`Failed to search LMS programs: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getExistingProgramMembers(
pairs: Array<{ programId: string; profileId: string }>
): Promise<Set<string>> {
+62 -1
View File
@@ -1,5 +1,5 @@
import type { TNewTag, TNewTagGroup, TTag, TTagAssignment, TTagGroup } from '@db/types';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { and, asc, eq, ilike, inArray, sql } from 'drizzle-orm';
import * as schema from '@db/schema';
import { db } from '@db/drizzle';
@@ -19,6 +19,26 @@ export interface TCourseTagPreview {
color: string;
}
export type TSearchOrgTag =
| {
kind: 'group';
id: string;
name: string;
slug: string;
description: string | null;
color: null;
groupName: null;
}
| {
kind: 'tag';
id: string;
name: string;
slug: string;
description: string | null;
color: string;
groupName: string;
};
interface GetTagGroupsWithTagsOptions {
onlyPublishedCourses?: boolean;
}
@@ -151,6 +171,47 @@ export async function getTagsByIds(orgId: string, tagIds: string[]): Promise<TTa
}
}
export async function searchOrgTagsAndGroups(orgId: string, search: string, limit: number): Promise<TSearchOrgTag[]> {
try {
const searchValue = `%${search.trim()}%`;
const [groups, tags] = await Promise.all([
db
.select({
id: schema.tagGroup.id,
name: schema.tagGroup.name,
slug: schema.tagGroup.slug,
description: schema.tagGroup.description
})
.from(schema.tagGroup)
.where(and(eq(schema.tagGroup.organizationId, orgId), ilike(schema.tagGroup.name, searchValue)))
.orderBy(asc(schema.tagGroup.name))
.limit(limit),
db
.select({
id: schema.tag.id,
name: schema.tag.name,
slug: schema.tag.slug,
description: schema.tag.description,
color: schema.tag.color,
groupName: schema.tagGroup.name
})
.from(schema.tag)
.innerJoin(schema.tagGroup, eq(schema.tag.groupId, schema.tagGroup.id))
.where(and(eq(schema.tag.organizationId, orgId), ilike(schema.tag.name, searchValue)))
.orderBy(asc(schema.tag.name))
.limit(limit)
]);
return [
...groups.map((group): TSearchOrgTag => ({ ...group, kind: 'group', color: null, groupName: null })),
...tags.map((tag): TSearchOrgTag => ({ ...tag, kind: 'tag' }))
].slice(0, limit);
} catch (error) {
console.error('searchOrgTagsAndGroups error:', error);
throw new Error('Failed to search tags and tag groups');
}
}
export async function createTagGroup(data: TNewTagGroup): Promise<TTagGroup> {
try {
const [result] = await db.insert(schema.tagGroup).values(data).returning();
+1
View File
@@ -0,0 +1 @@
export * from './widget';
+324
View File
@@ -0,0 +1,324 @@
import * as schema from '@db/schema';
import type {
TNewWidget,
TNewWidgetCourse,
TNewWidgetVersion,
TWidget,
TWidgetCourse,
TWidgetVersion
} from '@db/types';
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@db/drizzle';
export type TWidgetListItem = TWidget & {
courseCount: number;
};
function widgetListItemSelect() {
return {
id: schema.widget.id,
organizationId: schema.widget.organizationId,
name: schema.widget.name,
status: schema.widget.status,
layoutType: schema.widget.layoutType,
selectionMode: schema.widget.selectionMode,
publicKey: schema.widget.publicKey,
config: schema.widget.config,
hasUnpublishedChanges: schema.widget.hasUnpublishedChanges,
latestPublishedVersionId: schema.widget.latestPublishedVersionId,
createdByUserId: schema.widget.createdByUserId,
updatedByUserId: schema.widget.updatedByUserId,
deletedAt: schema.widget.deletedAt,
createdAt: schema.widget.createdAt,
updatedAt: schema.widget.updatedAt,
courseCount: sql<number>`COUNT(DISTINCT ${schema.widgetCourse.courseId})`.as('course_count')
};
}
export async function listWidgetsByOrganization(orgId: string): Promise<TWidgetListItem[]> {
try {
return db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), isNull(schema.widget.deletedAt)))
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name));
} catch (error) {
console.error('listWidgetsByOrganization error:', error);
throw new Error('Failed to list widgets');
}
}
export async function searchOrgWidgets(orgId: string, search: string, limit: number): Promise<TWidgetListItem[]> {
try {
const searchValue = `%${search.trim()}%`;
return await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
isNull(schema.widget.deletedAt),
ilike(schema.widget.name, searchValue)
)
)
.groupBy(schema.widget.id)
.orderBy(desc(schema.widget.updatedAt), asc(schema.widget.name))
.limit(limit);
} catch (error) {
console.error('searchOrgWidgets error:', error);
throw new Error('Failed to search widgets');
}
}
export async function getWidgetListItemById(orgId: string, widgetId: string): Promise<TWidgetListItem | null> {
try {
const [result] = await db
.select(widgetListItemSelect())
.from(schema.widget)
.leftJoin(schema.widgetCourse, eq(schema.widgetCourse.widgetId, schema.widget.id))
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.groupBy(schema.widget.id)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetListItemById error:', error);
throw new Error('Failed to get widget list item');
}
}
export async function getWidgetById(orgId: string, widgetId: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetById error:', error);
throw new Error('Failed to get widget');
}
}
export async function getWidgetByPublicKey(publicKey: string): Promise<TWidget | null> {
try {
const [result] = await db
.select()
.from(schema.widget)
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result ?? null;
} catch (error) {
console.error('getWidgetByPublicKey error:', error);
throw new Error('Failed to get widget by public key');
}
}
export async function createWidget(data: TNewWidget): Promise<TWidget> {
try {
const [result] = await db.insert(schema.widget).values(data).returning();
return result;
} catch (error) {
console.error('createWidget error:', error);
throw new Error('Failed to create widget');
}
}
export async function updateWidget(orgId: string, widgetId: string, data: Partial<TWidget>): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
...data,
updatedAt: new Date().toISOString()
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('updateWidget error:', error);
throw new Error('Failed to update widget');
}
}
export async function archiveWidget(orgId: string, widgetId: string, updatedByUserId: string): Promise<TWidget | null> {
try {
const [result] = await db
.update(schema.widget)
.set({
status: 'ARCHIVED',
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
updatedByUserId
})
.where(
and(eq(schema.widget.organizationId, orgId), eq(schema.widget.id, widgetId), isNull(schema.widget.deletedAt))
)
.returning();
return result ?? null;
} catch (error) {
console.error('archiveWidget error:', error);
throw new Error('Failed to archive widget');
}
}
export async function listWidgetCourses(widgetId: string): Promise<TWidgetCourse[]> {
try {
return db
.select()
.from(schema.widgetCourse)
.where(eq(schema.widgetCourse.widgetId, widgetId))
.orderBy(asc(schema.widgetCourse.order), asc(schema.widgetCourse.createdAt));
} catch (error) {
console.error('listWidgetCourses error:', error);
throw new Error('Failed to list widget courses');
}
}
export async function replaceWidgetCourses(widgetId: string, courseIds: string[]): Promise<TWidgetCourse[]> {
try {
const normalizedCourseIds = Array.from(new Set(courseIds));
return db.transaction(async (tx) => {
await tx.delete(schema.widgetCourse).where(eq(schema.widgetCourse.widgetId, widgetId));
if (normalizedCourseIds.length === 0) {
return [];
}
const rowsToInsert: TNewWidgetCourse[] = normalizedCourseIds.map((courseId, index) => ({
widgetId,
courseId,
order: index
}));
return tx.insert(schema.widgetCourse).values(rowsToInsert).returning();
});
} catch (error) {
console.error('replaceWidgetCourses error:', error);
throw new Error('Failed to replace widget courses');
}
}
export async function getNextWidgetVersion(widgetId: string): Promise<number> {
try {
const [result] = await db
.select({
latestVersion: sql<number>`COALESCE(MAX(${schema.widgetVersion.version}), 0)`.as('latest_version')
})
.from(schema.widgetVersion)
.where(eq(schema.widgetVersion.widgetId, widgetId));
return Number(result?.latestVersion ?? 0) + 1;
} catch (error) {
console.error('getNextWidgetVersion error:', error);
throw new Error('Failed to get next widget version');
}
}
export async function createWidgetVersion(data: TNewWidgetVersion): Promise<TWidgetVersion> {
try {
const [result] = await db.insert(schema.widgetVersion).values(data).returning();
return result;
} catch (error) {
console.error('createWidgetVersion error:', error);
throw new Error('Failed to create widget version');
}
}
export async function listWidgetVersions(orgId: string, widgetId: string): Promise<TWidgetVersion[]> {
try {
return db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(and(eq(schema.widget.organizationId, orgId), eq(schema.widgetVersion.widgetId, widgetId)))
.orderBy(desc(schema.widgetVersion.version))
.then((rows) => rows.map((row) => row.version));
} catch (error) {
console.error('listWidgetVersions error:', error);
throw new Error('Failed to list widget versions');
}
}
export async function getWidgetVersionById(
orgId: string,
widgetId: string,
versionId: string
): Promise<TWidgetVersion | null> {
try {
const [result] = await db
.select({ version: schema.widgetVersion })
.from(schema.widgetVersion)
.innerJoin(schema.widget, eq(schema.widgetVersion.widgetId, schema.widget.id))
.where(
and(
eq(schema.widget.organizationId, orgId),
eq(schema.widgetVersion.widgetId, widgetId),
eq(schema.widgetVersion.id, versionId)
)
)
.limit(1);
return result?.version ?? null;
} catch (error) {
console.error('getWidgetVersionById error:', error);
throw new Error('Failed to get widget version');
}
}
export async function getPublishedWidgetPayloadByPublicKey(publicKey: string): Promise<unknown | null> {
try {
const [result] = await db
.select({
payloadSnapshot: schema.widgetVersion.payloadSnapshot
})
.from(schema.widget)
.innerJoin(schema.widgetVersion, eq(schema.widget.latestPublishedVersionId, schema.widgetVersion.id))
.where(and(eq(schema.widget.publicKey, publicKey), isNull(schema.widget.deletedAt)))
.limit(1);
return result?.payloadSnapshot ?? null;
} catch (error) {
console.error('getPublishedWidgetPayloadByPublicKey error:', error);
throw new Error('Failed to get published widget payload');
}
}
export async function getWidgetsByIds(orgId: string, widgetIds: string[]): Promise<TWidget[]> {
try {
if (widgetIds.length === 0) {
return [];
}
return db
.select()
.from(schema.widget)
.where(
and(
eq(schema.widget.organizationId, orgId),
inArray(schema.widget.id, widgetIds),
isNull(schema.widget.deletedAt)
)
);
} catch (error) {
console.error('getWidgetsByIds error:', error);
throw new Error('Failed to get widgets by ids');
}
}