feat: widgets and ai chat
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from './course';
|
||||
export * from './course';
|
||||
export * from './compliance';
|
||||
export * from './content';
|
||||
export * from './people';
|
||||
export * from './invite';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './widget';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user