feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -58,7 +58,7 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
storeAccountCookie: true
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import type { TNewOrganization, TOrganization } from '@db/types';
|
||||
import { and, count, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
|
||||
export async function getAccountPrimary(orgId: string, dbClient: DbOrTxClient = db): Promise<TOrganization | null> {
|
||||
try {
|
||||
const [row] = await dbClient.select().from(schema.organization).where(eq(schema.organization.id, orgId)).limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (!row.parentOrganizationId) {
|
||||
return row as TOrganization;
|
||||
}
|
||||
|
||||
const [parent] = await dbClient
|
||||
.select()
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, row.parentOrganizationId))
|
||||
.limit(1);
|
||||
|
||||
return (parent ?? null) as TOrganization | null;
|
||||
} catch (error) {
|
||||
console.error('getAccountPrimary error:', error);
|
||||
throw new Error(`Failed to resolve account primary: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkspacesForAccount(
|
||||
primaryOrgId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TOrganization[]> {
|
||||
try {
|
||||
const rows = await dbClient
|
||||
.select()
|
||||
.from(schema.organization)
|
||||
.where(or(eq(schema.organization.id, primaryOrgId), eq(schema.organization.parentOrganizationId, primaryOrgId)));
|
||||
|
||||
return rows as TOrganization[];
|
||||
} catch (error) {
|
||||
console.error('listWorkspacesForAccount error:', error);
|
||||
throw new Error(`Failed to list account workspaces: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function countWorkspacesForAccount(primaryOrgId: string, dbClient: DbOrTxClient = db): Promise<number> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select({ count: count() })
|
||||
.from(schema.organization)
|
||||
.where(or(eq(schema.organization.id, primaryOrgId), eq(schema.organization.parentOrganizationId, primaryOrgId)));
|
||||
|
||||
return row?.count ?? 0;
|
||||
} catch (error) {
|
||||
console.error('countWorkspacesForAccount error:', error);
|
||||
throw new Error(`Failed to count account workspaces: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAccountOrgIds(primaryOrgId: string, dbClient: DbOrTxClient = db): Promise<string[]> {
|
||||
const rows = await listWorkspacesForAccount(primaryOrgId, dbClient);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
export async function getActivePlanForPrimary(
|
||||
primaryOrgId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select({ planName: schema.organizationPlan.planName })
|
||||
.from(schema.organizationPlan)
|
||||
.where(and(eq(schema.organizationPlan.orgId, primaryOrgId), eq(schema.organizationPlan.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
return row?.planName ?? null;
|
||||
} catch (error) {
|
||||
console.error('getActivePlanForPrimary error:', error);
|
||||
throw new Error(`Failed to fetch active plan: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateSecondaryWorkspaceInput = {
|
||||
parentOrganizationId: string;
|
||||
name: string;
|
||||
siteName: string;
|
||||
ownerProfileId: string;
|
||||
ownerRoleId: number;
|
||||
};
|
||||
|
||||
export async function createSecondaryWorkspace(input: CreateSecondaryWorkspaceInput): Promise<TOrganization> {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const insertValues: TNewOrganization = {
|
||||
name: input.name,
|
||||
siteName: input.siteName,
|
||||
parentOrganizationId: input.parentOrganizationId
|
||||
} as TNewOrganization;
|
||||
|
||||
const [created] = await tx.insert(schema.organization).values(insertValues).returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Insert returned no rows');
|
||||
}
|
||||
|
||||
await tx.insert(schema.organizationmember).values({
|
||||
organizationId: created.id,
|
||||
profileId: input.ownerProfileId,
|
||||
roleId: input.ownerRoleId,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return created as TOrganization;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createSecondaryWorkspace error:', error);
|
||||
throw new Error(
|
||||
`Failed to create secondary workspace: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSecondaryWorkspace(orgId: string): Promise<{ deleted: boolean; reason?: string }> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: schema.organization.id,
|
||||
parentOrganizationId: schema.organization.parentOrganizationId
|
||||
})
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return { deleted: false, reason: 'NOT_FOUND' };
|
||||
}
|
||||
|
||||
if (!row.parentOrganizationId) {
|
||||
return { deleted: false, reason: 'IS_PRIMARY' };
|
||||
}
|
||||
|
||||
await db.delete(schema.organization).where(eq(schema.organization.id, orgId));
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSecondaryWorkspace error:', error);
|
||||
throw new Error(`Failed to delete workspace: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSecondariesReadOnlyUntil(primaryOrgId: string, until: Date): Promise<number> {
|
||||
try {
|
||||
const result = await db
|
||||
.update(schema.organization)
|
||||
.set({ readOnlyUntil: until.toISOString() })
|
||||
.where(eq(schema.organization.parentOrganizationId, primaryOrgId))
|
||||
.returning({ id: schema.organization.id });
|
||||
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('setSecondariesReadOnlyUntil error:', error);
|
||||
throw new Error(`Failed to set secondaries read-only: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSecondariesReadOnly(primaryOrgId: string): Promise<number> {
|
||||
try {
|
||||
const result = await db
|
||||
.update(schema.organization)
|
||||
.set({ readOnlyUntil: null })
|
||||
.where(eq(schema.organization.parentOrganizationId, primaryOrgId))
|
||||
.returning({ id: schema.organization.id });
|
||||
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('clearSecondariesReadOnly error:', error);
|
||||
throw new Error(
|
||||
`Failed to clear secondaries read-only: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAccountsOverAllowance(
|
||||
allowanceByPlan: Record<string, number>
|
||||
): Promise<Array<{ primaryOrgId: string; planName: string | null; workspaceCount: number }>> {
|
||||
try {
|
||||
const primaries = await db
|
||||
.select({
|
||||
id: schema.organization.id,
|
||||
planName: schema.organizationPlan.planName
|
||||
})
|
||||
.from(schema.organization)
|
||||
.leftJoin(
|
||||
schema.organizationPlan,
|
||||
and(eq(schema.organization.id, schema.organizationPlan.orgId), eq(schema.organizationPlan.isActive, true))
|
||||
)
|
||||
.where(isNull(schema.organization.parentOrganizationId));
|
||||
|
||||
if (primaries.length === 0) return [];
|
||||
|
||||
const childCounts = await db
|
||||
.select({
|
||||
parentOrganizationId: schema.organization.parentOrganizationId,
|
||||
c: count()
|
||||
})
|
||||
.from(schema.organization)
|
||||
.where(
|
||||
inArray(
|
||||
schema.organization.parentOrganizationId,
|
||||
primaries.map((p) => p.id)
|
||||
)
|
||||
)
|
||||
.groupBy(schema.organization.parentOrganizationId);
|
||||
|
||||
const childCountByParent = new Map<string, number>();
|
||||
for (const row of childCounts) {
|
||||
if (row.parentOrganizationId) childCountByParent.set(row.parentOrganizationId, row.c);
|
||||
}
|
||||
|
||||
const offenders: Array<{ primaryOrgId: string; planName: string | null; workspaceCount: number }> = [];
|
||||
for (const primary of primaries) {
|
||||
const total = 1 + (childCountByParent.get(primary.id) ?? 0);
|
||||
const allowance = allowanceByPlan[primary.planName ?? ''] ?? 1;
|
||||
if (total > allowance) {
|
||||
offenders.push({ primaryOrgId: primary.id, planName: primary.planName, workspaceCount: total });
|
||||
}
|
||||
}
|
||||
|
||||
return offenders;
|
||||
} catch (error) {
|
||||
console.error('findAccountsOverAllowance error:', error);
|
||||
throw new Error(
|
||||
`Failed to find accounts over allowance: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './account';
|
||||
@@ -4,3 +4,5 @@ export * from './chat-resource';
|
||||
export * from './credit-purchase';
|
||||
export * from './token-usage';
|
||||
export * from './usage-aggregations';
|
||||
export * from './tutor-config';
|
||||
export * from './tutor-usage';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '@db/schema';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* AI tutor configuration queries.
|
||||
*
|
||||
* Org-level defaults live on `organization.aiTutorSettings` (JSONB column,
|
||||
* NOT NULL with a default). Per-course overrides live inside
|
||||
* `course.metadata.aiTutor` and are partial.
|
||||
*/
|
||||
|
||||
type OrgAiTutorSettings = NonNullable<typeof schema.organization.$inferSelect.aiTutorSettings>;
|
||||
type CourseMetadata = NonNullable<typeof schema.course.$inferSelect.metadata>;
|
||||
type CourseAiTutorOverride = NonNullable<CourseMetadata['aiTutor']>;
|
||||
|
||||
export async function getOrgAiTutorSettings(orgId: string): Promise<OrgAiTutorSettings | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ aiTutorSettings: schema.organization.aiTutorSettings })
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, orgId))
|
||||
.limit(1);
|
||||
|
||||
return row?.aiTutorSettings ?? null;
|
||||
} catch (error) {
|
||||
console.error('getOrgAiTutorSettings error:', error);
|
||||
throw new Error('Failed to fetch org AI tutor settings');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOrgAiTutorSettings(
|
||||
orgId: string,
|
||||
patch: Partial<OrgAiTutorSettings>
|
||||
): Promise<OrgAiTutorSettings | null> {
|
||||
try {
|
||||
const existing = await getOrgAiTutorSettings(orgId);
|
||||
if (!existing) return null;
|
||||
|
||||
const next: OrgAiTutorSettings = {
|
||||
...existing,
|
||||
...patch,
|
||||
escalation: { ...existing.escalation, ...(patch.escalation ?? {}) }
|
||||
};
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.organization)
|
||||
.set({ aiTutorSettings: next })
|
||||
.where(eq(schema.organization.id, orgId))
|
||||
.returning({ aiTutorSettings: schema.organization.aiTutorSettings });
|
||||
|
||||
return updated?.aiTutorSettings ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateOrgAiTutorSettings error:', error);
|
||||
throw new Error('Failed to update org AI tutor settings');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCourseAiTutorOverride(courseId: string): Promise<CourseAiTutorOverride | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ metadata: schema.course.metadata })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
return row?.metadata?.aiTutor ?? null;
|
||||
} catch (error) {
|
||||
console.error('getCourseAiTutorOverride error:', error);
|
||||
throw new Error('Failed to fetch course AI tutor override');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCourseAiTutorOverride(
|
||||
courseId: string,
|
||||
patch: Partial<CourseAiTutorOverride>
|
||||
): Promise<CourseAiTutorOverride | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ metadata: schema.course.metadata })
|
||||
.from(schema.course)
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const currentOverride = row.metadata?.aiTutor ?? {};
|
||||
const nextOverride: CourseAiTutorOverride = {
|
||||
...currentOverride,
|
||||
...patch,
|
||||
escalation: { ...currentOverride.escalation, ...(patch.escalation ?? {}) }
|
||||
};
|
||||
|
||||
const nextMetadata = {
|
||||
...(row.metadata ?? { allowNewStudent: true }),
|
||||
aiTutor: nextOverride
|
||||
};
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.course)
|
||||
.set({ metadata: nextMetadata, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.course.id, courseId))
|
||||
.returning({ metadata: schema.course.metadata });
|
||||
|
||||
return updated?.metadata?.aiTutor ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateCourseAiTutorOverride error:', error);
|
||||
throw new Error('Failed to update course AI tutor override');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { and, asc, desc, eq, gte, lte, sql, ilike, or } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '@db/schema';
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
/**
|
||||
* AI tutor usage queries — per-learner monthly message counts and cap events.
|
||||
*
|
||||
* The monthly counter is keyed by (orgId, userId, periodStart) and incremented
|
||||
* atomically via INSERT ... ON CONFLICT so concurrent requests cannot exceed
|
||||
* the cap.
|
||||
*/
|
||||
|
||||
export interface MonthlyTutorCount {
|
||||
messageCount: number;
|
||||
capHitAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
function periodStartString(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
return `${year}-${month}-01`;
|
||||
}
|
||||
|
||||
export async function getMonthlyTutorCount(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
periodStart: Date
|
||||
): Promise<MonthlyTutorCount> {
|
||||
try {
|
||||
const period = periodStartString(periodStart);
|
||||
const [row] = await db
|
||||
.select({
|
||||
messageCount: schema.aiTutorMessageCount.messageCount,
|
||||
capHitAt: schema.aiTutorMessageCount.capHitAt,
|
||||
lastMessageAt: schema.aiTutorMessageCount.lastMessageAt
|
||||
})
|
||||
.from(schema.aiTutorMessageCount)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiTutorMessageCount.orgId, orgId),
|
||||
eq(schema.aiTutorMessageCount.userId, userId),
|
||||
eq(schema.aiTutorMessageCount.periodStart, period)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
messageCount: row?.messageCount ?? 0,
|
||||
capHitAt: row?.capHitAt ?? null,
|
||||
lastMessageAt: row?.lastMessageAt ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getMonthlyTutorCount error:', error);
|
||||
throw new Error('Failed to fetch monthly tutor count');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increment the learner's monthly tutor counter and return the new
|
||||
* value. Uses INSERT ... ON CONFLICT so concurrent calls cannot race past the
|
||||
* cap.
|
||||
*/
|
||||
export async function incrementMonthlyTutorCount(params: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
periodStart: Date;
|
||||
cap: number;
|
||||
}): Promise<{ messageCount: number; capHit: boolean }> {
|
||||
try {
|
||||
const period = periodStartString(params.periodStart);
|
||||
const now = new Date().toISOString();
|
||||
const [row] = await db
|
||||
.insert(schema.aiTutorMessageCount)
|
||||
.values({
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
periodStart: period,
|
||||
messageCount: 1,
|
||||
lastMessageAt: now,
|
||||
capHitAt: null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.aiTutorMessageCount.orgId,
|
||||
schema.aiTutorMessageCount.userId,
|
||||
schema.aiTutorMessageCount.periodStart
|
||||
],
|
||||
set: {
|
||||
messageCount: sql`${schema.aiTutorMessageCount.messageCount} + 1`,
|
||||
lastMessageAt: now,
|
||||
updatedAt: now,
|
||||
capHitAt: sql`CASE
|
||||
WHEN ${schema.aiTutorMessageCount.capHitAt} IS NOT NULL THEN ${schema.aiTutorMessageCount.capHitAt}
|
||||
WHEN ${schema.aiTutorMessageCount.messageCount} + 1 >= ${params.cap} THEN ${now}
|
||||
ELSE NULL
|
||||
END`
|
||||
}
|
||||
})
|
||||
.returning({
|
||||
messageCount: schema.aiTutorMessageCount.messageCount,
|
||||
capHitAt: schema.aiTutorMessageCount.capHitAt
|
||||
});
|
||||
|
||||
return {
|
||||
messageCount: row?.messageCount ?? 0,
|
||||
capHit: !!row?.capHitAt
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('incrementMonthlyTutorCount error:', error);
|
||||
throw new Error('Failed to increment tutor counter');
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordCapEvent(params: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
courseId?: string;
|
||||
eventType: 'cap_reached' | 'pool_exhausted' | 'tutor_disabled';
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await db.insert(schema.aiTutorCapEvent).values({
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
courseId: params.courseId,
|
||||
eventType: params.eventType
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('recordCapEvent error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LearnerLeaderboardRow {
|
||||
userId: string;
|
||||
fullname: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
messageCount: number;
|
||||
tokens: number;
|
||||
capHitAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
export interface LearnerLeaderboardOptions {
|
||||
periodStart: Date;
|
||||
periodEnd?: Date;
|
||||
search?: string;
|
||||
sort?: 'messages' | 'tokens' | 'capPct';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Learners ranked by tutor activity in the given period.
|
||||
* Joins the monthly counter (the canonical message count) with aggregated
|
||||
* ai_token_usage (for per-learner token totals) and profile (for display).
|
||||
*/
|
||||
export async function getLearnerLeaderboard(
|
||||
orgId: string,
|
||||
options: LearnerLeaderboardOptions
|
||||
): Promise<{ rows: LearnerLeaderboardRow[]; total: number }> {
|
||||
try {
|
||||
const period = periodStartString(options.periodStart);
|
||||
const periodEndStr = options.periodEnd ? periodStartString(options.periodEnd) : period;
|
||||
const limit = options.limit ?? 20;
|
||||
const offset = options.offset ?? 0;
|
||||
const search = options.search?.trim();
|
||||
|
||||
const tokenSubquery = db
|
||||
.select({
|
||||
userId: schema.aiTokenUsage.userId,
|
||||
tokens:
|
||||
sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`.as(
|
||||
'tokens'
|
||||
)
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiTokenUsage.orgId, orgId),
|
||||
gte(schema.aiTokenUsage.createdAt, options.periodStart.toISOString()),
|
||||
options.periodEnd ? lte(schema.aiTokenUsage.createdAt, options.periodEnd.toISOString()) : undefined
|
||||
)
|
||||
)
|
||||
.groupBy(schema.aiTokenUsage.userId)
|
||||
.as('tokens_by_user');
|
||||
|
||||
const whereClause = and(
|
||||
eq(schema.aiTutorMessageCount.orgId, orgId),
|
||||
options.periodEnd
|
||||
? and(
|
||||
gte(schema.aiTutorMessageCount.periodStart, period),
|
||||
lte(schema.aiTutorMessageCount.periodStart, periodEndStr)
|
||||
)
|
||||
: eq(schema.aiTutorMessageCount.periodStart, period),
|
||||
search ? or(ilike(schema.profile.fullname, `%${search}%`), ilike(schema.profile.email, `%${search}%`)) : undefined
|
||||
);
|
||||
|
||||
const orderExpr =
|
||||
options.sort === 'tokens'
|
||||
? desc(tokenSubquery.tokens)
|
||||
: options.sort === 'capPct'
|
||||
? desc(schema.aiTutorMessageCount.messageCount)
|
||||
: desc(schema.aiTutorMessageCount.messageCount);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: schema.aiTutorMessageCount.userId,
|
||||
fullname: schema.profile.fullname,
|
||||
email: schema.profile.email,
|
||||
avatarUrl: schema.profile.avatarUrl,
|
||||
messageCount: schema.aiTutorMessageCount.messageCount,
|
||||
tokens: sql<number>`COALESCE(${tokenSubquery.tokens}, 0)`,
|
||||
capHitAt: schema.aiTutorMessageCount.capHitAt,
|
||||
lastMessageAt: schema.aiTutorMessageCount.lastMessageAt
|
||||
})
|
||||
.from(schema.aiTutorMessageCount)
|
||||
.leftJoin(schema.profile, eq(schema.profile.id, schema.aiTutorMessageCount.userId))
|
||||
.leftJoin(tokenSubquery, eq(tokenSubquery.userId, schema.aiTutorMessageCount.userId))
|
||||
.where(whereClause)
|
||||
.orderBy(orderExpr)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const [{ total }] = await db
|
||||
.select({ total: sql<number>`COUNT(*)` })
|
||||
.from(schema.aiTutorMessageCount)
|
||||
.leftJoin(schema.profile, eq(schema.profile.id, schema.aiTutorMessageCount.userId))
|
||||
.where(whereClause);
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
userId: row.userId,
|
||||
fullname: row.fullname,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
messageCount: Number(row.messageCount ?? 0),
|
||||
tokens: Number(row.tokens ?? 0),
|
||||
capHitAt: row.capHitAt,
|
||||
lastMessageAt: row.lastMessageAt
|
||||
})),
|
||||
total: Number(total ?? 0)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getLearnerLeaderboard error:', error);
|
||||
throw new Error('Failed to fetch learner leaderboard');
|
||||
}
|
||||
}
|
||||
|
||||
export interface CapStatusSummary {
|
||||
atCap: number;
|
||||
approaching: number;
|
||||
totalActive: number;
|
||||
}
|
||||
|
||||
export async function getCapStatusSummary(
|
||||
orgId: string,
|
||||
periodStart: Date,
|
||||
cap: number,
|
||||
approachingThreshold: number
|
||||
): Promise<CapStatusSummary> {
|
||||
try {
|
||||
const period = periodStartString(periodStart);
|
||||
const approachingFloor = Math.ceil(cap * approachingThreshold);
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalActive: sql<number>`COUNT(*)`,
|
||||
atCap: sql<number>`COUNT(*) FILTER (WHERE ${schema.aiTutorMessageCount.messageCount} >= ${cap})`,
|
||||
approaching: sql<number>`COUNT(*) FILTER (WHERE ${schema.aiTutorMessageCount.messageCount} >= ${approachingFloor} AND ${schema.aiTutorMessageCount.messageCount} < ${cap})`
|
||||
})
|
||||
.from(schema.aiTutorMessageCount)
|
||||
.where(and(eq(schema.aiTutorMessageCount.orgId, orgId), eq(schema.aiTutorMessageCount.periodStart, period)));
|
||||
|
||||
return {
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
atCap: Number(row?.atCap ?? 0),
|
||||
approaching: Number(row?.approaching ?? 0)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getCapStatusSummary error:', error);
|
||||
throw new Error('Failed to fetch cap status summary');
|
||||
}
|
||||
}
|
||||
|
||||
export interface LearnerDailyUsage {
|
||||
date: string;
|
||||
messages: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export async function getLearnerDailyUsage(orgId: string, userId: string, since: Date): Promise<LearnerDailyUsage[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
|
||||
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`,
|
||||
messages: sql<number>`COUNT(*)`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiTokenUsage.orgId, orgId),
|
||||
eq(schema.aiTokenUsage.userId, userId),
|
||||
gte(schema.aiTokenUsage.createdAt, since.toISOString())
|
||||
)
|
||||
)
|
||||
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
|
||||
.orderBy(asc(sql`DATE(${schema.aiTokenUsage.createdAt})`));
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: String(row.date),
|
||||
tokens: Number(row.tokens),
|
||||
messages: Number(row.messages)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getLearnerDailyUsage error:', error);
|
||||
throw new Error('Failed to fetch learner daily usage');
|
||||
}
|
||||
}
|
||||
|
||||
export interface LearnerCourseBreakdown {
|
||||
courseId: string;
|
||||
courseTitle: string | null;
|
||||
tokens: number;
|
||||
messages: number;
|
||||
}
|
||||
|
||||
export async function getLearnerCourseBreakdown(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
since: Date
|
||||
): Promise<LearnerCourseBreakdown[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
courseId: schema.aiTokenUsage.courseId,
|
||||
courseTitle: schema.course.title,
|
||||
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`,
|
||||
messages: sql<number>`COUNT(*)`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.leftJoin(schema.course, eq(schema.course.id, schema.aiTokenUsage.courseId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiTokenUsage.orgId, orgId),
|
||||
eq(schema.aiTokenUsage.userId, userId),
|
||||
gte(schema.aiTokenUsage.createdAt, since.toISOString())
|
||||
)
|
||||
)
|
||||
.groupBy(schema.aiTokenUsage.courseId, schema.course.title)
|
||||
.orderBy(
|
||||
desc(
|
||||
sql`SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}))`
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
courseId: row.courseId,
|
||||
courseTitle: row.courseTitle,
|
||||
tokens: Number(row.tokens),
|
||||
messages: Number(row.messages)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getLearnerCourseBreakdown error:', error);
|
||||
throw new Error('Failed to fetch learner course breakdown');
|
||||
}
|
||||
}
|
||||
|
||||
export interface LearnerCapEvent {
|
||||
id: number;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
courseId: string | null;
|
||||
}
|
||||
|
||||
export async function getLearnerCapEvents(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
since: Date,
|
||||
limit = 50
|
||||
): Promise<LearnerCapEvent[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.aiTutorCapEvent.id,
|
||||
eventType: schema.aiTutorCapEvent.eventType,
|
||||
occurredAt: schema.aiTutorCapEvent.occurredAt,
|
||||
courseId: schema.aiTutorCapEvent.courseId
|
||||
})
|
||||
.from(schema.aiTutorCapEvent)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiTutorCapEvent.orgId, orgId),
|
||||
eq(schema.aiTutorCapEvent.userId, userId),
|
||||
gte(schema.aiTutorCapEvent.occurredAt, since.toISOString())
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.aiTutorCapEvent.occurredAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
eventType: row.eventType,
|
||||
occurredAt: row.occurredAt,
|
||||
courseId: row.courseId
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getLearnerCapEvents error:', error);
|
||||
throw new Error('Failed to fetch learner cap events');
|
||||
}
|
||||
}
|
||||
@@ -320,3 +320,376 @@ export async function getProfileCourseProgress(courseId: string, profileId: stri
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engagement analytics — page event ingest + daily rollup upserts
|
||||
// Consumed by the @cio/analytics package.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PageEventInsert = typeof schema.analyticsPageEvent.$inferInsert;
|
||||
type OrgDailyInsert = typeof schema.analyticsOrgDaily.$inferInsert;
|
||||
type CourseDailyInsert = typeof schema.analyticsCourseDaily.$inferInsert;
|
||||
type CountryDailyInsert = typeof schema.analyticsCountryDaily.$inferInsert;
|
||||
|
||||
export async function insertPageEvents(events: PageEventInsert[]) {
|
||||
if (events.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await db
|
||||
.insert(schema.analyticsPageEvent)
|
||||
.values(events)
|
||||
.returning({ id: schema.analyticsPageEvent.id });
|
||||
return inserted.length;
|
||||
} catch (error) {
|
||||
console.error('insertPageEvents error:', error);
|
||||
throw new Error('Failed to insert page events');
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertOrgDailyRows(rows: OrgDailyInsert[]) {
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.analyticsOrgDaily)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.analyticsOrgDaily.orgId, schema.analyticsOrgDaily.date],
|
||||
set: {
|
||||
landingViews: sql`excluded.landing_views`,
|
||||
uniqueVisitors: sql`excluded.unique_visitors`,
|
||||
coursePageViews: sql`excluded.course_page_views`,
|
||||
enrollments: sql`excluded.enrollments`,
|
||||
completions: sql`excluded.completions`,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.analyticsOrgDaily.id });
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('upsertOrgDailyRows error:', error);
|
||||
throw new Error('Failed to upsert analytics_org_daily rows');
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertCourseDailyRows(rows: CourseDailyInsert[]) {
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.analyticsCourseDaily)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.analyticsCourseDaily.courseId, schema.analyticsCourseDaily.date],
|
||||
set: {
|
||||
orgId: sql`excluded.org_id`,
|
||||
views: sql`excluded.views`,
|
||||
uniqueVisitors: sql`excluded.unique_visitors`,
|
||||
enrollments: sql`excluded.enrollments`,
|
||||
completions: sql`excluded.completions`,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.analyticsCourseDaily.id });
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('upsertCourseDailyRows error:', error);
|
||||
throw new Error('Failed to upsert analytics_course_daily rows');
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertCountryDailyRows(rows: CountryDailyInsert[]) {
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.insert(schema.analyticsCountryDaily)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.analyticsCountryDaily.orgId,
|
||||
schema.analyticsCountryDaily.date,
|
||||
schema.analyticsCountryDaily.country
|
||||
],
|
||||
set: {
|
||||
views: sql`excluded.views`,
|
||||
enrollments: sql`excluded.enrollments`,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
.returning({ id: schema.analyticsCountryDaily.id });
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('upsertCountryDailyRows error:', error);
|
||||
throw new Error('Failed to upsert analytics_country_daily rows');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-aggregated org/date counts pulled from raw page events for the rollup job.
|
||||
* One row per (org_id, date) for events in [fromIso, toIso).
|
||||
*/
|
||||
export async function selectOrgDailyAggregates(fromIso: string, toIso: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
orgId: schema.analyticsPageEvent.orgId,
|
||||
date: sql<string>`(${schema.analyticsPageEvent.occurredAt} AT TIME ZONE 'UTC')::date`.as('date'),
|
||||
landingViews:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'landing_view')::int`.as(
|
||||
'landing_views'
|
||||
),
|
||||
coursePageViews:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'course_page_view')::int`.as(
|
||||
'course_page_views'
|
||||
),
|
||||
uniqueVisitors: sql<number>`COUNT(DISTINCT ${schema.analyticsPageEvent.sessionId})::int`.as('unique_visitors'),
|
||||
enrollments:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'enrollment_completed')::int`.as(
|
||||
'enrollments'
|
||||
),
|
||||
completions:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'course_completed')::int`.as(
|
||||
'completions'
|
||||
)
|
||||
})
|
||||
.from(schema.analyticsPageEvent)
|
||||
.where(
|
||||
and(
|
||||
sql`${schema.analyticsPageEvent.occurredAt} >= ${fromIso}`,
|
||||
sql`${schema.analyticsPageEvent.occurredAt} < ${toIso}`,
|
||||
sql`${schema.analyticsPageEvent.orgId} IS NOT NULL`
|
||||
)
|
||||
)
|
||||
.groupBy(schema.analyticsPageEvent.orgId, sql`date`);
|
||||
} catch (error) {
|
||||
console.error('selectOrgDailyAggregates error:', error);
|
||||
throw new Error('Failed to aggregate org/date page events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-aggregated course/date counts from raw page events for the rollup job.
|
||||
*/
|
||||
export async function selectCourseDailyAggregates(fromIso: string, toIso: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
courseId: schema.analyticsPageEvent.courseId,
|
||||
orgId: schema.analyticsPageEvent.orgId,
|
||||
date: sql<string>`(${schema.analyticsPageEvent.occurredAt} AT TIME ZONE 'UTC')::date`.as('date'),
|
||||
views: sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'course_page_view')::int`.as(
|
||||
'views'
|
||||
),
|
||||
uniqueVisitors: sql<number>`COUNT(DISTINCT ${schema.analyticsPageEvent.sessionId})::int`.as('unique_visitors'),
|
||||
enrollments:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'enrollment_completed')::int`.as(
|
||||
'enrollments'
|
||||
),
|
||||
completions:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'course_completed')::int`.as(
|
||||
'completions'
|
||||
)
|
||||
})
|
||||
.from(schema.analyticsPageEvent)
|
||||
.where(
|
||||
and(
|
||||
sql`${schema.analyticsPageEvent.occurredAt} >= ${fromIso}`,
|
||||
sql`${schema.analyticsPageEvent.occurredAt} < ${toIso}`,
|
||||
sql`${schema.analyticsPageEvent.courseId} IS NOT NULL`,
|
||||
sql`${schema.analyticsPageEvent.orgId} IS NOT NULL`
|
||||
)
|
||||
)
|
||||
.groupBy(schema.analyticsPageEvent.courseId, schema.analyticsPageEvent.orgId, sql`date`);
|
||||
} catch (error) {
|
||||
console.error('selectCourseDailyAggregates error:', error);
|
||||
throw new Error('Failed to aggregate course/date page events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-aggregated org/date/country counts from raw page events for the rollup job.
|
||||
*/
|
||||
export async function selectCountryDailyAggregates(fromIso: string, toIso: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
orgId: schema.analyticsPageEvent.orgId,
|
||||
date: sql<string>`(${schema.analyticsPageEvent.occurredAt} AT TIME ZONE 'UTC')::date`.as('date'),
|
||||
country: schema.analyticsPageEvent.country,
|
||||
views:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} IN ('landing_view', 'course_page_view'))::int`.as(
|
||||
'views'
|
||||
),
|
||||
enrollments:
|
||||
sql<number>`COUNT(*) FILTER (WHERE ${schema.analyticsPageEvent.eventType} = 'enrollment_completed')::int`.as(
|
||||
'enrollments'
|
||||
)
|
||||
})
|
||||
.from(schema.analyticsPageEvent)
|
||||
.where(
|
||||
and(
|
||||
sql`${schema.analyticsPageEvent.occurredAt} >= ${fromIso}`,
|
||||
sql`${schema.analyticsPageEvent.occurredAt} < ${toIso}`,
|
||||
sql`${schema.analyticsPageEvent.orgId} IS NOT NULL`,
|
||||
sql`${schema.analyticsPageEvent.country} IS NOT NULL`
|
||||
)
|
||||
)
|
||||
.groupBy(schema.analyticsPageEvent.orgId, sql`date`, schema.analyticsPageEvent.country);
|
||||
} catch (error) {
|
||||
console.error('selectCountryDailyAggregates error:', error);
|
||||
throw new Error('Failed to aggregate country page events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate enrollments and course-page views by `course.type` for an org
|
||||
* in a date range. Joins the daily course rollup with the canonical course
|
||||
* row so the result groups by the enum (SELF_PACED / LIVE_CLASS / COMPLIANCE
|
||||
* / PUBLIC).
|
||||
*/
|
||||
export async function selectPopularCourseTypes(orgId: string, fromDate: string, toDate: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
type: schema.course.type,
|
||||
enrollments: sql<number>`SUM(${schema.analyticsCourseDaily.enrollments})::int`.as('enrollments'),
|
||||
views: sql<number>`SUM(${schema.analyticsCourseDaily.views})::int`.as('views'),
|
||||
completions: sql<number>`SUM(${schema.analyticsCourseDaily.completions})::int`.as('completions'),
|
||||
courseCount: sql<number>`COUNT(DISTINCT ${schema.analyticsCourseDaily.courseId})::int`.as('course_count')
|
||||
})
|
||||
.from(schema.analyticsCourseDaily)
|
||||
.innerJoin(schema.course, eq(schema.course.id, schema.analyticsCourseDaily.courseId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.analyticsCourseDaily.orgId, orgId),
|
||||
sql`${schema.analyticsCourseDaily.date} >= ${fromDate}`,
|
||||
sql`${schema.analyticsCourseDaily.date} <= ${toDate}`
|
||||
)
|
||||
)
|
||||
.groupBy(schema.course.type)
|
||||
.orderBy(desc(sql`SUM(${schema.analyticsCourseDaily.enrollments})`));
|
||||
} catch (error) {
|
||||
console.error('selectPopularCourseTypes error:', error);
|
||||
throw new Error('Failed to select popular course types');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-to-be-forgotten: null out user_id on past events for a given user.
|
||||
* Rollups remain (aggregate only); raw events stay queryable but anonymous.
|
||||
*/
|
||||
export async function deidentifyPageEventsForUser(userId: string) {
|
||||
try {
|
||||
const result = await db
|
||||
.update(schema.analyticsPageEvent)
|
||||
.set({ userId: null })
|
||||
.where(eq(schema.analyticsPageEvent.userId, userId))
|
||||
.returning({ id: schema.analyticsPageEvent.id });
|
||||
return result.length;
|
||||
} catch (error) {
|
||||
console.error('deidentifyPageEventsForUser error:', error);
|
||||
throw new Error('Failed to deidentify page events for user');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-side aggregations for dashboard analytics endpoints.
|
||||
// All return rollup-derived data so reads are O(daysInRange) at most.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-day org rollup rows for a date range, ordered ascending by date.
|
||||
* Used by landing-stats (totals + sparkline) and course-funnel.
|
||||
*/
|
||||
export async function selectOrgDailyRange(orgId: string, fromDate: string, toDate: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
date: schema.analyticsOrgDaily.date,
|
||||
landingViews: schema.analyticsOrgDaily.landingViews,
|
||||
uniqueVisitors: schema.analyticsOrgDaily.uniqueVisitors,
|
||||
coursePageViews: schema.analyticsOrgDaily.coursePageViews,
|
||||
enrollments: schema.analyticsOrgDaily.enrollments,
|
||||
completions: schema.analyticsOrgDaily.completions
|
||||
})
|
||||
.from(schema.analyticsOrgDaily)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.analyticsOrgDaily.orgId, orgId),
|
||||
sql`${schema.analyticsOrgDaily.date} >= ${fromDate}`,
|
||||
sql`${schema.analyticsOrgDaily.date} <= ${toDate}`
|
||||
)
|
||||
)
|
||||
.orderBy(schema.analyticsOrgDaily.date);
|
||||
} catch (error) {
|
||||
console.error('selectOrgDailyRange error:', error);
|
||||
throw new Error('Failed to select org daily range');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day rows for a single course in a date range. If the courseId belongs
|
||||
* to a different org the result is empty (no row matches both filters).
|
||||
*/
|
||||
export async function selectCourseDailyRange(courseId: string, orgId: string, fromDate: string, toDate: string) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
date: schema.analyticsCourseDaily.date,
|
||||
views: schema.analyticsCourseDaily.views,
|
||||
uniqueVisitors: schema.analyticsCourseDaily.uniqueVisitors,
|
||||
enrollments: schema.analyticsCourseDaily.enrollments,
|
||||
completions: schema.analyticsCourseDaily.completions
|
||||
})
|
||||
.from(schema.analyticsCourseDaily)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.analyticsCourseDaily.courseId, courseId),
|
||||
eq(schema.analyticsCourseDaily.orgId, orgId),
|
||||
sql`${schema.analyticsCourseDaily.date} >= ${fromDate}`,
|
||||
sql`${schema.analyticsCourseDaily.date} <= ${toDate}`
|
||||
)
|
||||
)
|
||||
.orderBy(schema.analyticsCourseDaily.date);
|
||||
} catch (error) {
|
||||
console.error('selectCourseDailyRange error:', error);
|
||||
throw new Error('Failed to select course daily range');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top countries by views for an org over a date range.
|
||||
*/
|
||||
export async function selectCountryBreakdown(orgId: string, fromDate: string, toDate: string, limit: number = 20) {
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
country: schema.analyticsCountryDaily.country,
|
||||
views: sql<number>`SUM(${schema.analyticsCountryDaily.views})::int`.as('views'),
|
||||
enrollments: sql<number>`SUM(${schema.analyticsCountryDaily.enrollments})::int`.as('enrollments')
|
||||
})
|
||||
.from(schema.analyticsCountryDaily)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.analyticsCountryDaily.orgId, orgId),
|
||||
sql`${schema.analyticsCountryDaily.date} >= ${fromDate}`,
|
||||
sql`${schema.analyticsCountryDaily.date} <= ${toDate}`
|
||||
)
|
||||
)
|
||||
.groupBy(schema.analyticsCountryDaily.country)
|
||||
.orderBy(desc(sql`SUM(${schema.analyticsCountryDaily.views})`))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('selectCountryBreakdown error:', error);
|
||||
throw new Error('Failed to select country breakdown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,3 +472,94 @@ export async function listLatestComplianceRecordsForReminderScan(): Promise<Cour
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type OrgComplianceLearnerRow = {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
groupMemberId: string;
|
||||
profileId: string | null;
|
||||
profile: CourseMemberProfile | null;
|
||||
email: string | null;
|
||||
status: TCourseCompletionRecord['status'] | 'no_record';
|
||||
cycleNumber: number | null;
|
||||
dueDate: string | null;
|
||||
completedAt: string | null;
|
||||
validUntil: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Latest compliance record per (course, learner) for every COMPLIANCE-type
|
||||
* course in the org. One row per enrolled student × course.
|
||||
*/
|
||||
export async function getOrgComplianceLearnerRows(orgId: string): Promise<OrgComplianceLearnerRow[]> {
|
||||
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_org_compliance_cycles');
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
courseId: schema.course.id,
|
||||
courseTitle: schema.course.title,
|
||||
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.course)
|
||||
.innerJoin(schema.group, eq(schema.group.id, schema.course.groupId))
|
||||
.innerJoin(schema.groupmember, eq(schema.groupmember.groupId, schema.group.id))
|
||||
.leftJoin(schema.profile, eq(schema.profile.id, schema.groupmember.profileId))
|
||||
.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.group.organizationId, orgId),
|
||||
eq(schema.course.type, 'COMPLIANCE'),
|
||||
eq(schema.groupmember.roleId, ROLE.STUDENT),
|
||||
isNotNull(schema.groupmember.profileId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(schema.course.title), asc(schema.profile.fullname));
|
||||
|
||||
return result.map((row) => ({
|
||||
courseId: row.courseId,
|
||||
courseTitle: row.courseTitle,
|
||||
groupMemberId: row.member.id,
|
||||
profileId: row.member.profileId,
|
||||
profile: row.profile ?? null,
|
||||
email: row.profile?.email ?? row.member.email ?? null,
|
||||
status: (row.record?.status as TCourseCompletionRecord['status'] | undefined) ?? 'no_record',
|
||||
cycleNumber: row.record?.cycleNumber ?? null,
|
||||
dueDate: row.record?.dueDate ?? null,
|
||||
completedAt: row.record?.completedAt ?? null,
|
||||
validUntil: row.record?.validUntil ?? null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getOrgComplianceLearnerRows error:', error);
|
||||
throw new Error(
|
||||
`Failed to get org compliance learner rows: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PublicCourseHeader {
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
animation: 'waves' | 'dotted' | 'none';
|
||||
} | null;
|
||||
org: {
|
||||
id: string;
|
||||
@@ -245,13 +246,17 @@ export async function getPublicCourseTreeBySlug(courseSlug: string): Promise<Pub
|
||||
});
|
||||
|
||||
const rawCallout = courseRow.callout;
|
||||
const callout =
|
||||
const callout: PublicCourseHeader['callout'] =
|
||||
rawCallout && typeof rawCallout === 'object' && 'title' in rawCallout
|
||||
? {
|
||||
title: String((rawCallout as { title: unknown }).title ?? ''),
|
||||
description: String((rawCallout as { description: unknown }).description ?? ''),
|
||||
buttonLabel: String((rawCallout as { buttonLabel: unknown }).buttonLabel ?? ''),
|
||||
buttonUrl: String((rawCallout as { buttonUrl: unknown }).buttonUrl ?? '')
|
||||
buttonUrl: String((rawCallout as { buttonUrl: unknown }).buttonUrl ?? ''),
|
||||
animation: (() => {
|
||||
const raw = (rawCallout as { animation: unknown }).animation;
|
||||
return raw === 'dotted' || raw === 'none' ? raw : 'waves';
|
||||
})()
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -287,7 +292,17 @@ export interface PublicLessonContent {
|
||||
video: {
|
||||
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
|
||||
link: string;
|
||||
/** S3 object key for uploads; used server-side for presigning (stripped before API response). */
|
||||
key?: string;
|
||||
assetId?: string;
|
||||
transcript?: {
|
||||
vttUrl: string;
|
||||
vttUrlExpiresAt: string;
|
||||
language: string;
|
||||
} | null;
|
||||
} | null;
|
||||
/** Internal: resolved in the API layer for presigned transcript URLs; not returned to clients. */
|
||||
courseOrganizationId?: string | null;
|
||||
}
|
||||
|
||||
export interface PublicExerciseContent {
|
||||
@@ -320,11 +335,13 @@ export async function getPublicCourseItem(
|
||||
const [courseRow] = await db
|
||||
.select({
|
||||
id: schema.course.id,
|
||||
organizationId: schema.group.organizationId,
|
||||
type: schema.course.type,
|
||||
isPublished: schema.course.isPublished,
|
||||
status: schema.course.status
|
||||
})
|
||||
.from(schema.course)
|
||||
.leftJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||
.where(eq(schema.course.slug, courseSlug))
|
||||
.limit(1);
|
||||
|
||||
@@ -376,12 +393,16 @@ export async function getPublicCourseItem(
|
||||
Array.isArray(lessonRow.videos) && lessonRow.videos.length > 0
|
||||
? {
|
||||
type: lessonRow.videos[0].type,
|
||||
link: lessonRow.videos[0].link
|
||||
link: lessonRow.videos[0].link,
|
||||
assetId: lessonRow.videos[0].assetId,
|
||||
...(lessonRow.videos[0].key ? { key: lessonRow.videos[0].key } : {})
|
||||
}
|
||||
: lessonRow.videoUrl
|
||||
? { type: 'generic' as const, link: lessonRow.videoUrl }
|
||||
: null;
|
||||
|
||||
const isLocked = !lessonRow.isUnlocked;
|
||||
|
||||
return {
|
||||
kind: 'lesson',
|
||||
id: lessonRow.id,
|
||||
@@ -389,8 +410,9 @@ export async function getPublicCourseItem(
|
||||
title: lessonRow.title,
|
||||
sectionTitle: section[0]?.title ?? null,
|
||||
isUnlocked: lessonRow.isUnlocked ?? false,
|
||||
body,
|
||||
video: firstVideo
|
||||
body: isLocked ? '' : body,
|
||||
video: isLocked ? null : firstVideo,
|
||||
courseOrganizationId: courseRow.organizationId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -437,7 +459,7 @@ export async function getPublicCourseItem(
|
||||
id: exerciseRow.id,
|
||||
slug: exerciseRow.slug ?? itemSlug,
|
||||
title: exerciseRow.title,
|
||||
description: exerciseRow.description,
|
||||
description: exerciseRow.isUnlocked ? exerciseRow.description : null,
|
||||
sectionTitle: section[0]?.title ?? null,
|
||||
isUnlocked: exerciseRow.isUnlocked ?? true,
|
||||
allowMultipleAttempts: exerciseRow.allowMultipleAttempts,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './organization';
|
||||
export * from './account';
|
||||
export * from './query-errors';
|
||||
export * from './auth';
|
||||
export * from './attendance';
|
||||
@@ -16,3 +17,5 @@ export * from './tag';
|
||||
export * from './course-import';
|
||||
export * from './program';
|
||||
export * from './widget';
|
||||
export * from './jobs';
|
||||
export * from './media-transcript';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, desc, eq, lt } from '@db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TDeadLetterJob, TNewDeadLetterJob } from '@db/types';
|
||||
|
||||
/**
|
||||
* Dead-letter ledger. Populated on attempt exhaustion or explicit failure;
|
||||
* consumed by the admin UI for replay/triage and by maintenance retention.
|
||||
*/
|
||||
|
||||
export async function recordDeadLetterJob(
|
||||
values: TNewDeadLetterJob,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TDeadLetterJob> {
|
||||
try {
|
||||
const [row] = await dbClient.insert(schema.deadLetterJob).values(values).returning();
|
||||
if (!row) {
|
||||
throw new Error('Insert returned no row');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('recordDeadLetterJob error:', error);
|
||||
throw new Error('Failed to record dead-letter job');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDeadLetterJobs(
|
||||
options: { domain?: string; organizationId?: string; limit?: number } = {}
|
||||
): Promise<TDeadLetterJob[]> {
|
||||
try {
|
||||
const limit = options.limit ?? 100;
|
||||
const conditions = [];
|
||||
if (options.domain) {
|
||||
conditions.push(eq(schema.deadLetterJob.domain, options.domain));
|
||||
}
|
||||
if (options.organizationId) {
|
||||
conditions.push(eq(schema.deadLetterJob.organizationId, options.organizationId));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.deadLetterJob)
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(schema.deadLetterJob.createdAt))
|
||||
.limit(limit);
|
||||
} catch (error) {
|
||||
console.error('listDeadLetterJobs error:', error);
|
||||
throw new Error('Failed to list dead-letter jobs');
|
||||
}
|
||||
}
|
||||
|
||||
export async function markDeadLetterReplayed(deadLetterId: string, dbClient: DbOrTxClient = db): Promise<void> {
|
||||
try {
|
||||
await dbClient
|
||||
.update(schema.deadLetterJob)
|
||||
.set({ replayedAt: new Date().toISOString() })
|
||||
.where(eq(schema.deadLetterJob.id, deadLetterId));
|
||||
} catch (error) {
|
||||
console.error('markDeadLetterReplayed error:', error);
|
||||
throw new Error('Failed to mark dead-letter job replayed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneDeadLetterJobsOlderThan(isoCutoff: string, dbClient: DbOrTxClient = db): Promise<number> {
|
||||
try {
|
||||
const deleted = await dbClient
|
||||
.delete(schema.deadLetterJob)
|
||||
.where(lt(schema.deadLetterJob.createdAt, isoCutoff))
|
||||
.returning({ id: schema.deadLetterJob.id });
|
||||
|
||||
return deleted.length;
|
||||
} catch (error) {
|
||||
console.error('pruneDeadLetterJobsOlderThan error:', error);
|
||||
throw new Error('Failed to prune dead-letter jobs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './media-job';
|
||||
export * from './job-step';
|
||||
export * from './dead-letter-job';
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq } from '@db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TJobStatus, TJobStep, TNewJobStep } from '@db/types';
|
||||
|
||||
/**
|
||||
* Generic checkpoint ledger. Workers look up `(domain, runId, stepKey)` first
|
||||
* and short-circuit when the step has a result, making BullMQ retries safe
|
||||
* even after partial completion.
|
||||
*/
|
||||
|
||||
export interface UpsertJobStepInput {
|
||||
domain: string;
|
||||
runId: string;
|
||||
stepKey: string;
|
||||
status: TJobStatus;
|
||||
inputHash?: string | null;
|
||||
providerId?: string | null;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: { code: string; message: string; details?: unknown } | null;
|
||||
attempt?: number;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
export async function getJobStep(
|
||||
domain: string,
|
||||
runId: string,
|
||||
stepKey: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TJobStep | null> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select()
|
||||
.from(schema.jobStep)
|
||||
.where(
|
||||
and(eq(schema.jobStep.domain, domain), eq(schema.jobStep.runId, runId), eq(schema.jobStep.stepKey, stepKey))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getJobStep error:', error);
|
||||
throw new Error('Failed to get job step');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listJobStepsByRun(
|
||||
domain: string,
|
||||
runId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TJobStep[]> {
|
||||
try {
|
||||
return await dbClient
|
||||
.select()
|
||||
.from(schema.jobStep)
|
||||
.where(and(eq(schema.jobStep.domain, domain), eq(schema.jobStep.runId, runId)));
|
||||
} catch (error) {
|
||||
console.error('listJobStepsByRun error:', error);
|
||||
throw new Error('Failed to list job steps');
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertJobStep(input: UpsertJobStepInput, dbClient: DbOrTxClient = db): Promise<TJobStep> {
|
||||
try {
|
||||
const values: TNewJobStep = {
|
||||
domain: input.domain,
|
||||
runId: input.runId,
|
||||
stepKey: input.stepKey,
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
providerId: input.providerId ?? null,
|
||||
result: input.result ?? null,
|
||||
error: input.error ?? null,
|
||||
attempt: input.attempt ?? 0,
|
||||
startedAt: input.startedAt ?? null,
|
||||
finishedAt: input.finishedAt ?? null
|
||||
};
|
||||
|
||||
const [row] = await dbClient
|
||||
.insert(schema.jobStep)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.jobStep.domain, schema.jobStep.runId, schema.jobStep.stepKey],
|
||||
set: {
|
||||
status: input.status,
|
||||
inputHash: input.inputHash ?? null,
|
||||
providerId: input.providerId ?? null,
|
||||
result: input.result ?? null,
|
||||
error: input.error ?? null,
|
||||
attempt: input.attempt ?? 0,
|
||||
startedAt: input.startedAt ?? null,
|
||||
finishedAt: input.finishedAt ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Upsert returned no row');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('upsertJobStep error:', error);
|
||||
throw new Error('Failed to upsert job step');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, desc, eq, inArray, sql } from '@db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TJobStatus, TMediaJob, TNewMediaJob } from '@db/types';
|
||||
|
||||
/**
|
||||
* Pure DB queries for `media_job`. Routes/services compose these and never
|
||||
* call drizzle directly. Catch blocks log the original error so failures are
|
||||
* traceable from API logs.
|
||||
*/
|
||||
|
||||
export interface UpdateMediaJobInput {
|
||||
status?: TJobStatus;
|
||||
stage?: string | null;
|
||||
progressPercent?: number | null;
|
||||
rootJobId?: string | null;
|
||||
jobIds?: Record<string, string>;
|
||||
attempt?: number;
|
||||
costCents?: number;
|
||||
cancelRequestedAt?: string | null;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: { code: string; message: string; details?: unknown } | null;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export async function createMediaJob(values: TNewMediaJob, dbClient: DbOrTxClient = db): Promise<TMediaJob> {
|
||||
try {
|
||||
const [created] = await dbClient.insert(schema.mediaJob).values(values).returning();
|
||||
if (!created) {
|
||||
throw new Error('Insert returned no row');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('createMediaJob error:', error);
|
||||
throw new Error('Failed to create media job');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaJobById(jobId: string, orgId?: string): Promise<TMediaJob | null> {
|
||||
try {
|
||||
const conditions = [eq(schema.mediaJob.id, jobId)];
|
||||
if (orgId) {
|
||||
conditions.push(eq(schema.mediaJob.organizationId, orgId));
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.mediaJob)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getMediaJobById error:', error);
|
||||
throw new Error('Failed to get media job by id');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMediaJobsByIds(jobIds: string[], orgId?: string): Promise<TMediaJob[]> {
|
||||
try {
|
||||
if (jobIds.length === 0) return [];
|
||||
|
||||
const conditions = [inArray(schema.mediaJob.id, jobIds)];
|
||||
if (orgId) {
|
||||
conditions.push(eq(schema.mediaJob.organizationId, orgId));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.mediaJob)
|
||||
.where(and(...conditions));
|
||||
} catch (error) {
|
||||
console.error('listMediaJobsByIds error:', error);
|
||||
throw new Error('Failed to list media jobs by ids');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLatestMediaJobsByAsset(assetId: string, orgId: string): Promise<TMediaJob[]> {
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.mediaJob)
|
||||
.where(and(eq(schema.mediaJob.assetId, assetId), eq(schema.mediaJob.organizationId, orgId)))
|
||||
.orderBy(desc(schema.mediaJob.createdAt));
|
||||
} catch (error) {
|
||||
console.error('listLatestMediaJobsByAsset error:', error);
|
||||
throw new Error('Failed to list latest media jobs for asset');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMediaJob(
|
||||
jobId: string,
|
||||
patch: UpdateMediaJobInput,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaJob | null> {
|
||||
try {
|
||||
const [updated] = await dbClient
|
||||
.update(schema.mediaJob)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.mediaJob.id, jobId))
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateMediaJob error:', error);
|
||||
throw new Error('Failed to update media job');
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestMediaJobCancel(
|
||||
jobId: string,
|
||||
orgId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaJob | null> {
|
||||
try {
|
||||
const [updated] = await dbClient
|
||||
.update(schema.mediaJob)
|
||||
.set({
|
||||
cancelRequestedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.mediaJob.id, jobId),
|
||||
eq(schema.mediaJob.organizationId, orgId),
|
||||
// Don't reset cancellation if the worker already finished.
|
||||
sql`${schema.mediaJob.status} not in ('completed', 'failed', 'canceled')`
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('requestMediaJobCancel error:', error);
|
||||
throw new Error('Failed to request media job cancel');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './media-transcript';
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, eq, inArray } from '@db/drizzle';
|
||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
||||
import type { TMediaTranscript, TNewMediaTranscript } from '@db/types';
|
||||
|
||||
export type UpsertMediaTranscriptInput = Omit<TNewMediaTranscript, 'createdAt' | 'updatedAt' | 'id'> & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert or replace transcript for an asset (one row per asset).
|
||||
*/
|
||||
export async function upsertMediaTranscript(
|
||||
input: UpsertMediaTranscriptInput,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaTranscript> {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const [row] = await dbClient
|
||||
.insert(schema.mediaTranscript)
|
||||
.values({
|
||||
...input,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.mediaTranscript.assetId,
|
||||
set: {
|
||||
organizationId: input.organizationId,
|
||||
mediaJobId: input.mediaJobId ?? null,
|
||||
language: input.language,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
text: input.text,
|
||||
segments: input.segments,
|
||||
vttStorageKey: input.vttStorageKey,
|
||||
vttBucket: input.vttBucket,
|
||||
durationSeconds: input.durationSeconds ?? null,
|
||||
costCents: input.costCents,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Upsert returned no row');
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.error('upsertMediaTranscript error:', error);
|
||||
throw new Error('Failed to upsert media transcript');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaTranscriptByAsset(
|
||||
assetId: string,
|
||||
organizationId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaTranscript | null> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select()
|
||||
.from(schema.mediaTranscript)
|
||||
.where(
|
||||
and(eq(schema.mediaTranscript.assetId, assetId), eq(schema.mediaTranscript.organizationId, organizationId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ?? null;
|
||||
} catch (error) {
|
||||
console.error('getMediaTranscriptByAsset error:', error);
|
||||
throw new Error('Failed to get media transcript by asset');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMediaTranscriptsByAssetIds(
|
||||
assetIds: string[],
|
||||
organizationId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<TMediaTranscript[]> {
|
||||
try {
|
||||
if (assetIds.length === 0) return [];
|
||||
|
||||
return await dbClient
|
||||
.select()
|
||||
.from(schema.mediaTranscript)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.mediaTranscript.assetId, assetIds),
|
||||
eq(schema.mediaTranscript.organizationId, organizationId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('listMediaTranscriptsByAssetIds error:', error);
|
||||
throw new Error('Failed to list media transcripts by asset ids');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any media_job for this asset is still queued or running.
|
||||
*/
|
||||
export async function hasActiveMediaJobForAsset(
|
||||
assetId: string,
|
||||
organizationId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const [row] = await dbClient
|
||||
.select({ id: schema.mediaJob.id })
|
||||
.from(schema.mediaJob)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.mediaJob.assetId, assetId),
|
||||
eq(schema.mediaJob.organizationId, organizationId),
|
||||
inArray(schema.mediaJob.status, ['queued', 'running'])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row !== undefined;
|
||||
} catch (error) {
|
||||
console.error('hasActiveMediaJobForAsset error:', error);
|
||||
throw new Error('Failed to check active media job');
|
||||
}
|
||||
}
|
||||
@@ -953,7 +953,24 @@ export const getActiveOrganizationPlan = async (orgId: string) => {
|
||||
.where(and(eq(schema.organizationPlan.orgId, orgId), eq(schema.organizationPlan.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
return plan ?? null;
|
||||
if (plan) return plan;
|
||||
|
||||
// Secondary workspaces inherit their primary's plan.
|
||||
const [org] = await db
|
||||
.select({ parentOrganizationId: schema.organization.parentOrganizationId })
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org?.parentOrganizationId) return null;
|
||||
|
||||
const [parentPlan] = await db
|
||||
.select()
|
||||
.from(schema.organizationPlan)
|
||||
.where(and(eq(schema.organizationPlan.orgId, org.parentOrganizationId), eq(schema.organizationPlan.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
return parentPlan ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@db/drizzle';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TProgramGoal = typeof schema.programGoal.$inferSelect;
|
||||
export type TNewProgramGoal = typeof schema.programGoal.$inferInsert;
|
||||
export type TProgramGoalAssignment = typeof schema.programGoalAssignment.$inferSelect;
|
||||
export type TNewProgramGoalAssignment = typeof schema.programGoalAssignment.$inferInsert;
|
||||
|
||||
// ─── Goal CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProgramGoal(data: TNewProgramGoal): Promise<TProgramGoal> {
|
||||
try {
|
||||
const [goal] = await db.insert(schema.programGoal).values(data).returning();
|
||||
if (!goal) throw new Error('Failed to create program goal');
|
||||
|
||||
return goal;
|
||||
} catch (error) {
|
||||
console.error('createProgramGoal error:', error);
|
||||
throw new Error(`Failed to create program goal: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalById(goalId: string): Promise<TProgramGoal | null> {
|
||||
try {
|
||||
const [goal] = await db.select().from(schema.programGoal).where(eq(schema.programGoal.id, goalId)).limit(1);
|
||||
|
||||
return goal ?? null;
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalById error:', error);
|
||||
throw new Error(
|
||||
`Failed to get program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoals(
|
||||
programId: string,
|
||||
options: { includeArchived?: boolean } = {}
|
||||
): Promise<TProgramGoal[]> {
|
||||
try {
|
||||
const conditions = [eq(schema.programGoal.programId, programId)];
|
||||
if (!options.includeArchived) {
|
||||
conditions.push(eq(schema.programGoal.status, 'active'));
|
||||
}
|
||||
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoal)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(schema.programGoal.createdAt));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoals error:', error);
|
||||
throw new Error(`Failed to list program goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalsByOrg(
|
||||
organizationId: string
|
||||
): Promise<Array<TProgramGoal & { programId: string; programName: string }>> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
goal: schema.programGoal,
|
||||
programName: schema.program.name
|
||||
})
|
||||
.from(schema.programGoal)
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.program.organizationId, organizationId), eq(schema.programGoal.status, 'active')));
|
||||
|
||||
return result.map((row) => ({ ...row.goal, programName: row.programName }));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalsByOrg error:', error);
|
||||
throw new Error(`Failed to list goals for org: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every active goal across every org. Used by the daily cron sweep. */
|
||||
export async function listAllActiveProgramGoals(): Promise<TProgramGoal[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoal).where(eq(schema.programGoal.status, 'active'));
|
||||
} catch (error) {
|
||||
console.error('listAllActiveProgramGoals error:', error);
|
||||
throw new Error(`Failed to list active goals: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For the reminder scan: every assignment whose `dueDate` is in the future and
|
||||
* whose status is not yet completed/waived. The caller decides the cadence
|
||||
* (compares daysUntilDue against goal.reminderDaysBefore).
|
||||
*/
|
||||
export async function listAssignmentsForReminderScan(): Promise<
|
||||
Array<{
|
||||
assignment: TProgramGoalAssignment;
|
||||
goal: TProgramGoal;
|
||||
profileId: string;
|
||||
email: string | null;
|
||||
programId: string;
|
||||
programName: string;
|
||||
organizationName: string;
|
||||
organizationSiteName: string | null;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
profileId: schema.programMember.profileId,
|
||||
email: schema.programMember.email,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name,
|
||||
organizationName: schema.organization.name,
|
||||
organizationSiteName: schema.organization.siteName
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.innerJoin(schema.organization, eq(schema.program.organizationId, schema.organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.programGoal.status, 'active'),
|
||||
inArray(schema.programGoalAssignment.status, ['not_started', 'in_progress', 'at_risk', 'overdue'])
|
||||
)
|
||||
);
|
||||
|
||||
return result
|
||||
.filter((row) => row.profileId != null)
|
||||
.map((row) => ({
|
||||
assignment: row.assignment,
|
||||
goal: row.goal,
|
||||
profileId: row.profileId!,
|
||||
email: row.email,
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
organizationName: row.organizationName,
|
||||
organizationSiteName: row.organizationSiteName
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('listAssignmentsForReminderScan error:', error);
|
||||
throw new Error(
|
||||
`Failed to list assignments for reminder scan: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProgramGoal(goalId: string, data: Partial<TNewProgramGoal>): Promise<TProgramGoal | null> {
|
||||
try {
|
||||
const [updated] = await db
|
||||
.update(schema.programGoal)
|
||||
.set({ ...data, updatedAt: sql`now()` })
|
||||
.where(eq(schema.programGoal.id, goalId))
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
} catch (error) {
|
||||
console.error('updateProgramGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to update program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProgramGoal(goalId: string): Promise<TProgramGoal | null> {
|
||||
try {
|
||||
const [deleted] = await db.delete(schema.programGoal).where(eq(schema.programGoal.id, goalId)).returning();
|
||||
|
||||
return deleted ?? null;
|
||||
} catch (error) {
|
||||
console.error('deleteProgramGoal error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete program goal "${goalId}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Goal Assignments ────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertProgramGoalAssignments(
|
||||
rows: TNewProgramGoalAssignment[]
|
||||
): Promise<TProgramGoalAssignment[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.insert(schema.programGoalAssignment)
|
||||
.values(rows)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.programGoalAssignment.goalId, schema.programGoalAssignment.programMemberId],
|
||||
set: {
|
||||
dueDate: sql`excluded.due_date`,
|
||||
status: sql`excluded.status`,
|
||||
completedCount: sql`excluded.completed_count`,
|
||||
requiredCount: sql`excluded.required_count`,
|
||||
completedAt: sql`excluded.completed_at`,
|
||||
lastEvaluatedAt: sql`excluded.last_evaluated_at`,
|
||||
updatedAt: sql`now()`
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
} catch (error) {
|
||||
console.error('upsertProgramGoalAssignments error:', error);
|
||||
throw new Error(`Failed to upsert goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProgramGoalAssignments(goalId: string): Promise<TProgramGoalAssignment[]> {
|
||||
try {
|
||||
return await db.select().from(schema.programGoalAssignment).where(eq(schema.programGoalAssignment.goalId, goalId));
|
||||
} catch (error) {
|
||||
console.error('getProgramGoalAssignments error:', error);
|
||||
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignmentsForProgramMembers(programMemberIds: string[]): Promise<TProgramGoalAssignment[]> {
|
||||
if (programMemberIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.programMemberId, programMemberIds));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProgramMembers error:', error);
|
||||
throw new Error(`Failed to get goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAssignmentsForProfile(profileId: string): Promise<
|
||||
Array<
|
||||
TProgramGoalAssignment & {
|
||||
goal: TProgramGoal;
|
||||
programId: string;
|
||||
programName: string;
|
||||
}
|
||||
>
|
||||
> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
assignment: schema.programGoalAssignment,
|
||||
goal: schema.programGoal,
|
||||
programId: schema.program.id,
|
||||
programName: schema.program.name
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
|
||||
.innerJoin(schema.programGoal, eq(schema.programGoalAssignment.goalId, schema.programGoal.id))
|
||||
.innerJoin(schema.program, eq(schema.programGoal.programId, schema.program.id))
|
||||
.where(and(eq(schema.programMember.profileId, profileId), eq(schema.programGoal.status, 'active')));
|
||||
|
||||
return result.map((row) => ({
|
||||
...row.assignment,
|
||||
goal: row.goal,
|
||||
programId: row.programId,
|
||||
programName: row.programName
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getAssignmentsForProfile error:', error);
|
||||
throw new Error(
|
||||
`Failed to get goal assignments for profile: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-goal status counts for a program (used by dashboard tiles).
|
||||
*/
|
||||
export async function countAssignmentsByStatus(goalIds: string[]): Promise<Map<string, Record<string, number>>> {
|
||||
if (goalIds.length === 0) return new Map();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
goalId: schema.programGoalAssignment.goalId,
|
||||
status: schema.programGoalAssignment.status,
|
||||
count: sql<number>`count(*)::int`.as('count')
|
||||
})
|
||||
.from(schema.programGoalAssignment)
|
||||
.where(inArray(schema.programGoalAssignment.goalId, goalIds))
|
||||
.groupBy(schema.programGoalAssignment.goalId, schema.programGoalAssignment.status);
|
||||
|
||||
const map = new Map<string, Record<string, number>>();
|
||||
for (const row of rows) {
|
||||
const bucket = map.get(row.goalId) ?? {};
|
||||
bucket[row.status] = Number(row.count) || 0;
|
||||
map.set(row.goalId, bucket);
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error('countAssignmentsByStatus error:', error);
|
||||
throw new Error(`Failed to count goal assignments: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Evaluator Inputs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* For each (profile, course) pair, return the most recent course_completion_record
|
||||
* (cycle-aware compliance status) plus optional score. Used by the goal evaluator.
|
||||
*/
|
||||
export async function getLatestCompletionRecordsForProfilesAndCourses(
|
||||
profileIds: string[],
|
||||
courseIds: string[]
|
||||
): Promise<
|
||||
Array<{ profileId: string; courseId: string; status: string; score: number | null; completedAt: string | null }>
|
||||
> {
|
||||
if (profileIds.length === 0 || courseIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
profileId: schema.courseCompletionRecord.profileId,
|
||||
courseId: schema.courseCompletionRecord.courseId,
|
||||
status: schema.courseCompletionRecord.status,
|
||||
score: schema.courseCompletionRecord.score,
|
||||
completedAt: schema.courseCompletionRecord.completedAt,
|
||||
cycleNumber: schema.courseCompletionRecord.cycleNumber
|
||||
})
|
||||
.from(schema.courseCompletionRecord)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.courseCompletionRecord.profileId, profileIds),
|
||||
inArray(schema.courseCompletionRecord.courseId, courseIds)
|
||||
)
|
||||
);
|
||||
|
||||
const latest = new Map<string, (typeof rows)[number]>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.profileId}:${row.courseId}`;
|
||||
const prev = latest.get(key);
|
||||
if (!prev || (row.cycleNumber ?? 0) > (prev.cycleNumber ?? 0)) {
|
||||
latest.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
return [...latest.values()].map((row) => ({
|
||||
profileId: row.profileId,
|
||||
courseId: row.courseId,
|
||||
status: row.status,
|
||||
score: row.score ?? null,
|
||||
completedAt: row.completedAt ?? null
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('getLatestCompletionRecordsForProfilesAndCourses error:', error);
|
||||
throw new Error(`Failed to load completion records: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback completion source for non-compliance courses: a learner is considered
|
||||
* "done" with a course when every lesson in the course has a `lesson_completion`
|
||||
* row marked completed. Returns set of (profileId, courseId) keys that match.
|
||||
*/
|
||||
export async function getNonComplianceCourseCompletions(
|
||||
profileIds: string[],
|
||||
courseIds: string[]
|
||||
): Promise<Set<string>> {
|
||||
if (profileIds.length === 0 || courseIds.length === 0) return new Set();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
profileId: schema.lessonCompletion.profileId,
|
||||
courseId: schema.lesson.courseId,
|
||||
totalLessons: sql<number>`COUNT(DISTINCT ${schema.lesson.id})::int`.as('totalLessons'),
|
||||
completedLessons: sql<number>`
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN ${schema.lessonCompletion.isComplete} = true THEN ${schema.lessonCompletion.lessonId}
|
||||
ELSE NULL
|
||||
END)::int
|
||||
`.as('completedLessons')
|
||||
})
|
||||
.from(schema.lesson)
|
||||
.leftJoin(
|
||||
schema.lessonCompletion,
|
||||
and(
|
||||
eq(schema.lessonCompletion.lessonId, schema.lesson.id),
|
||||
inArray(schema.lessonCompletion.profileId, profileIds)
|
||||
)
|
||||
)
|
||||
.where(inArray(schema.lesson.courseId, courseIds))
|
||||
.groupBy(schema.lessonCompletion.profileId, schema.lesson.courseId);
|
||||
|
||||
const completed = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.profileId || !row.courseId) continue;
|
||||
if (row.totalLessons > 0 && row.completedLessons >= row.totalLessons) {
|
||||
completed.add(`${row.profileId}:${row.courseId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
} catch (error) {
|
||||
console.error('getNonComplianceCourseCompletions error:', error);
|
||||
throw new Error(`Failed to load lesson completions: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For score / pass_rate goals: pull the highest score per (profile, course)
|
||||
* across compliance records (which already store the assessment score).
|
||||
*/
|
||||
export async function getMaxScoresForProfilesAndCourses(
|
||||
profileIds: string[],
|
||||
courseIds: string[]
|
||||
): Promise<Map<string, number>> {
|
||||
if (profileIds.length === 0 || courseIds.length === 0) return new Map();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
profileId: schema.courseCompletionRecord.profileId,
|
||||
courseId: schema.courseCompletionRecord.courseId,
|
||||
maxScore: sql<number | null>`MAX(${schema.courseCompletionRecord.score})`.as('maxScore')
|
||||
})
|
||||
.from(schema.courseCompletionRecord)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.courseCompletionRecord.profileId, profileIds),
|
||||
inArray(schema.courseCompletionRecord.courseId, courseIds)
|
||||
)
|
||||
)
|
||||
.groupBy(schema.courseCompletionRecord.profileId, schema.courseCompletionRecord.courseId);
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.maxScore == null) continue;
|
||||
map.set(`${row.profileId}:${row.courseId}`, Number(row.maxScore));
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error('getMaxScoresForProfilesAndCourses error:', error);
|
||||
throw new Error(`Failed to load scores: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './program';
|
||||
export * from './goal';
|
||||
|
||||
@@ -117,7 +117,7 @@ export const groupRelations = relations(group, ({ one, many }) => ({
|
||||
groupmembers: many(groupmember)
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
export const organizationRelations = relations(organization, ({ one, many }) => ({
|
||||
groups: many(group),
|
||||
assets: many(asset),
|
||||
assetUsages: many(assetUsage),
|
||||
@@ -128,7 +128,13 @@ export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
organizationPlans: many(organizationPlan),
|
||||
organizationmembers: many(organizationmember),
|
||||
tagGroups: many(tagGroup),
|
||||
tags: many(tag)
|
||||
tags: many(tag),
|
||||
parentOrganization: one(organization, {
|
||||
fields: [organization.parentOrganizationId],
|
||||
references: [organization.id],
|
||||
relationName: 'organizationAccount'
|
||||
}),
|
||||
childWorkspaces: many(organization, { relationName: 'organizationAccount' })
|
||||
}));
|
||||
|
||||
export const tagGroupRelations = relations(tagGroup, ({ one, many }) => ({
|
||||
|
||||
+585
-9
@@ -186,6 +186,92 @@ export const analyticsLoginEvents = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const analyticsPageEvent = pgTable(
|
||||
'analytics_page_event',
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
orgId: uuid('org_id'),
|
||||
sessionId: text('session_id').notNull(),
|
||||
userId: uuid('user_id'),
|
||||
eventType: text('event_type').notNull(),
|
||||
courseId: uuid('course_id'),
|
||||
path: text('path'),
|
||||
referrerHost: text('referrer_host'),
|
||||
utmSource: text('utm_source'),
|
||||
utmMedium: text('utm_medium'),
|
||||
utmCampaign: text('utm_campaign'),
|
||||
country: varchar('country', { length: 2 }),
|
||||
deviceType: varchar('device_type', { length: 16 }),
|
||||
locale: varchar('locale', { length: 8 }),
|
||||
props: jsonb().default({}).notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_ape_org_occurred').on(table.orgId, table.occurredAt),
|
||||
index('idx_ape_course_occurred').on(table.courseId, table.occurredAt),
|
||||
index('idx_ape_session').on(table.sessionId),
|
||||
index('idx_ape_event_type').on(table.eventType),
|
||||
index('idx_ape_user').on(table.userId)
|
||||
]
|
||||
);
|
||||
|
||||
export const analyticsOrgDaily = pgTable(
|
||||
'analytics_org_daily',
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
orgId: uuid('org_id').notNull(),
|
||||
date: date('date').notNull(),
|
||||
landingViews: integer('landing_views').default(0).notNull(),
|
||||
uniqueVisitors: integer('unique_visitors').default(0).notNull(),
|
||||
coursePageViews: integer('course_page_views').default(0).notNull(),
|
||||
enrollments: integer('enrollments').default(0).notNull(),
|
||||
completions: integer('completions').default(0).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique('analytics_org_daily_org_date_unique').on(table.orgId, table.date),
|
||||
index('idx_aod_org_date').on(table.orgId, table.date)
|
||||
]
|
||||
);
|
||||
|
||||
export const analyticsCourseDaily = pgTable(
|
||||
'analytics_course_daily',
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
courseId: uuid('course_id').notNull(),
|
||||
orgId: uuid('org_id').notNull(),
|
||||
date: date('date').notNull(),
|
||||
views: integer('views').default(0).notNull(),
|
||||
uniqueVisitors: integer('unique_visitors').default(0).notNull(),
|
||||
enrollments: integer('enrollments').default(0).notNull(),
|
||||
completions: integer('completions').default(0).notNull(),
|
||||
avgTimeToEnrollSeconds: integer('avg_time_to_enroll_seconds'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique('analytics_course_daily_course_date_unique').on(table.courseId, table.date),
|
||||
index('idx_acd_org_date').on(table.orgId, table.date),
|
||||
index('idx_acd_course_date').on(table.courseId, table.date)
|
||||
]
|
||||
);
|
||||
|
||||
export const analyticsCountryDaily = pgTable(
|
||||
'analytics_country_daily',
|
||||
{
|
||||
id: uuid().defaultRandom().primaryKey().notNull(),
|
||||
orgId: uuid('org_id').notNull(),
|
||||
date: date('date').notNull(),
|
||||
country: varchar('country', { length: 2 }).notNull(),
|
||||
views: integer('views').default(0).notNull(),
|
||||
enrollments: integer('enrollments').default(0).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique('analytics_country_daily_org_date_country_unique').on(table.orgId, table.date, table.country),
|
||||
index('idx_acntd_org_date').on(table.orgId, table.date)
|
||||
]
|
||||
);
|
||||
|
||||
export const courseSection = pgTable(
|
||||
'course_section',
|
||||
{
|
||||
@@ -600,6 +686,27 @@ export const course = pgTable(
|
||||
allowNewStudent: boolean;
|
||||
sectionDisplay?: Record<string, boolean>;
|
||||
isContentGroupingEnabled?: boolean;
|
||||
/** Per-course AI tutor override; missing fields inherit from org defaults. */
|
||||
aiTutor?: {
|
||||
inheritFromOrg?: boolean;
|
||||
enabled?: boolean;
|
||||
persona?: 'friendly' | 'formal' | 'encouraging' | 'socratic' | 'custom';
|
||||
customPersona?: string;
|
||||
responseLength?: 'short' | 'medium' | 'long';
|
||||
welcomeMessage?: string;
|
||||
disclaimerFooter?: string;
|
||||
thingsToSay?: string;
|
||||
thingsNotToSay?: string;
|
||||
forbiddenTopics?: string[];
|
||||
groundingScope?: 'lesson' | 'course' | 'workspace';
|
||||
requireCitations?: boolean;
|
||||
assessmentMode?: 'direct_answer' | 'hint_only' | 'block_during_exercise';
|
||||
revealSolutionsAfterAttempts?: number;
|
||||
codePolicy?: 'allowed' | 'hints_only' | 'forbidden';
|
||||
blockOffTopic?: boolean;
|
||||
profanityFilter?: boolean;
|
||||
escalation?: { enabled?: boolean; email?: string };
|
||||
};
|
||||
}>(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
cost: bigint({ mode: 'number' }).default(sql`'0'`),
|
||||
@@ -608,7 +715,17 @@ export const course = pgTable(
|
||||
isPublished: boolean('is_published').default(false),
|
||||
certificate: jsonb().default({}).$type<{
|
||||
isDownloadable?: boolean;
|
||||
/** @deprecated Use `design.templateId`. Legacy 6-theme id; mapped on read via LEGACY_THEME_MAP. */
|
||||
theme?: string;
|
||||
/** Atelier-era certificate design. Source of truth for new courses. */
|
||||
design?: {
|
||||
templateId: 'classique' | 'brutalist' | 'noir' | 'poster' | 'minimal';
|
||||
accentColor: string;
|
||||
subtitle?: string;
|
||||
descriptionOverride?: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
idFormat?: string;
|
||||
};
|
||||
deadline?: string | null;
|
||||
threshold?: number;
|
||||
requiredExerciseId?: string | null;
|
||||
@@ -631,6 +748,7 @@ export const course = pgTable(
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
buttonUrl: string;
|
||||
animation?: 'waves' | 'dotted' | 'none';
|
||||
} | null>()
|
||||
},
|
||||
(table) => [
|
||||
@@ -1904,13 +2022,41 @@ export const organization = pgTable(
|
||||
openInNewTab: boolean;
|
||||
}>;
|
||||
};
|
||||
footer?: {
|
||||
facebook: string;
|
||||
instagram: string;
|
||||
twitter: string;
|
||||
linkedin: string;
|
||||
show: boolean;
|
||||
};
|
||||
/** Legacy social URLs-only footer block */
|
||||
footer?:
|
||||
| {
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
show?: boolean;
|
||||
}
|
||||
| {
|
||||
brand: {
|
||||
tagline?: string;
|
||||
copyright?: string;
|
||||
socials: Array<{ platform: string; href: string }>;
|
||||
};
|
||||
columns: Array<{
|
||||
id: string;
|
||||
heading: string;
|
||||
links: Array<{ label: string; href: string }>;
|
||||
cta?: { label: string; href: string };
|
||||
}>;
|
||||
bottom?: {
|
||||
text?: string;
|
||||
links: Array<{ label: string; href: string }>;
|
||||
};
|
||||
};
|
||||
/** Current landing JSON shape (stored alongside legacy keys above) */
|
||||
theme?: string;
|
||||
hero?: Record<string, unknown>;
|
||||
navItems?: Array<{ label: string; href: string }>;
|
||||
footerLinks?: Array<{ label: string; href: string }>;
|
||||
footerText?: string;
|
||||
embed?: Record<string, unknown>;
|
||||
callout?: Record<string, unknown>;
|
||||
links?: Record<string, unknown>;
|
||||
}>(),
|
||||
theme: text().default('blue'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
|
||||
@@ -1947,11 +2093,59 @@ export const organization = pgTable(
|
||||
disableSignup: boolean('disable_signup').default(false),
|
||||
disableSignupMessage: text('disable_signup_message'),
|
||||
disableEmailPassword: boolean('disable_email_password').default(false),
|
||||
disableGoogleAuth: boolean('disable_google_auth').default(false)
|
||||
disableGoogleAuth: boolean('disable_google_auth').default(false),
|
||||
parentOrganizationId: uuid('parent_organization_id'),
|
||||
readOnlyUntil: timestamp('read_only_until', { withTimezone: true, mode: 'string' }),
|
||||
aiTutorSettings: jsonb('ai_tutor_settings')
|
||||
.default({
|
||||
enabled: true,
|
||||
persona: 'encouraging',
|
||||
customPersona: '',
|
||||
responseLength: 'medium',
|
||||
welcomeMessage: '',
|
||||
disclaimerFooter: 'I am an AI tutor, not your instructor.',
|
||||
thingsToSay: '',
|
||||
thingsNotToSay: '',
|
||||
forbiddenTopics: [],
|
||||
groundingScope: 'course',
|
||||
requireCitations: true,
|
||||
assessmentMode: 'hint_only',
|
||||
revealSolutionsAfterAttempts: 3,
|
||||
codePolicy: 'hints_only',
|
||||
blockOffTopic: true,
|
||||
profanityFilter: true,
|
||||
escalation: { enabled: false, email: '' }
|
||||
})
|
||||
.$type<{
|
||||
enabled: boolean;
|
||||
persona: 'friendly' | 'formal' | 'encouraging' | 'socratic' | 'custom';
|
||||
customPersona: string;
|
||||
responseLength: 'short' | 'medium' | 'long';
|
||||
welcomeMessage: string;
|
||||
disclaimerFooter: string;
|
||||
thingsToSay: string;
|
||||
thingsNotToSay: string;
|
||||
forbiddenTopics: string[];
|
||||
groundingScope: 'lesson' | 'course' | 'workspace';
|
||||
requireCitations: boolean;
|
||||
assessmentMode: 'direct_answer' | 'hint_only' | 'block_during_exercise';
|
||||
revealSolutionsAfterAttempts: number;
|
||||
codePolicy: 'allowed' | 'hints_only' | 'forbidden';
|
||||
blockOffTopic: boolean;
|
||||
profanityFilter: boolean;
|
||||
escalation: { enabled: boolean; email: string };
|
||||
}>()
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique('organization_siteName_key').on(table.siteName),
|
||||
unique('organization_customDomain_key').on(table.customDomain)
|
||||
unique('organization_customDomain_key').on(table.customDomain),
|
||||
foreignKey({
|
||||
columns: [table.parentOrganizationId],
|
||||
foreignColumns: [table.id],
|
||||
name: 'organization_parent_organization_id_fkey'
|
||||
}).onDelete('set null'),
|
||||
index('idx_organization_parent_id').on(table.parentOrganizationId)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2593,6 +2787,127 @@ export const programNewsfeedComment = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ─── Program Goals ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A team-level "is the team done" target. A small generalisation of the
|
||||
// per-course `course.compliance` field: applies across multiple courses, scoped
|
||||
// to a program's members, and aggregated for owner roll-ups. Per-learner status
|
||||
// is materialised in `programGoalAssignment` so dashboards stay cheap.
|
||||
|
||||
export const programGoalType = pgEnum('PROGRAM_GOAL_TYPE', [
|
||||
'complete_all',
|
||||
'n_of_m',
|
||||
'score',
|
||||
'pass_rate',
|
||||
'readiness'
|
||||
]);
|
||||
|
||||
export const programGoalDeadlineKind = pgEnum('PROGRAM_GOAL_DEADLINE_KIND', [
|
||||
'absolute',
|
||||
'relative_to_join',
|
||||
'recurring',
|
||||
'none'
|
||||
]);
|
||||
|
||||
export const programGoalStatus = pgEnum('PROGRAM_GOAL_STATUS', ['active', 'archived']);
|
||||
|
||||
export const programGoalAssignmentStatus = pgEnum('PROGRAM_GOAL_ASSIGNMENT_STATUS', [
|
||||
'not_started',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'at_risk',
|
||||
'overdue',
|
||||
'waived'
|
||||
]);
|
||||
|
||||
export const programGoal = pgTable(
|
||||
'program_goal',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
programId: uuid('program_id').notNull(),
|
||||
title: varchar({ length: 255 }).notNull(),
|
||||
description: text(),
|
||||
type: programGoalType().notNull(),
|
||||
/** Course IDs the goal targets. JSONB so we can attach per-course config later if needed. */
|
||||
courseIds: jsonb('course_ids').$type<string[]>().default([]).notNull(),
|
||||
/** Required only for n_of_m. */
|
||||
requiredCount: integer('required_count'),
|
||||
/** Per-learner score threshold (0–100). Required for `score` and `pass_rate`. */
|
||||
scoreThreshold: integer('score_threshold'),
|
||||
/** Team-wide pass-rate threshold (0–100). Required for `pass_rate`. */
|
||||
teamPassRateThreshold: integer('team_pass_rate_threshold'),
|
||||
deadlineKind: programGoalDeadlineKind('deadline_kind').default('none').notNull(),
|
||||
/** Used when deadlineKind = 'absolute'. */
|
||||
deadlineDate: timestamp('deadline_date', { withTimezone: true, mode: 'string' }),
|
||||
/** Used when deadlineKind = 'relative_to_join'. */
|
||||
relativeDays: integer('relative_days'),
|
||||
/** Used when deadlineKind = 'recurring'. */
|
||||
recurringMonths: integer('recurring_months'),
|
||||
/** Reminder cadence (days before due). Mirrors `course.compliance.reminderDaysBefore`. */
|
||||
reminderDaysBefore: jsonb('reminder_days_before').$type<number[]>().default([7, 1]).notNull(),
|
||||
status: programGoalStatus().default('active').notNull(),
|
||||
createdByProfileId: uuid('created_by_profile_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.programId],
|
||||
foreignColumns: [program.id],
|
||||
name: 'program_goal_program_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.createdByProfileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'program_goal_created_by_profile_id_fkey'
|
||||
}).onDelete('set null'),
|
||||
index('idx_program_goal_program_id').on(table.programId),
|
||||
index('idx_program_goal_status').on(table.status)
|
||||
]
|
||||
);
|
||||
|
||||
export const programGoalAssignment = pgTable(
|
||||
'program_goal_assignment',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
goalId: uuid('goal_id').notNull(),
|
||||
programMemberId: uuid('program_member_id').notNull(),
|
||||
/** Resolved due date for this learner (null when goal has no deadline). */
|
||||
dueDate: timestamp('due_date', { withTimezone: true, mode: 'string' }),
|
||||
status: programGoalAssignmentStatus().default('not_started').notNull(),
|
||||
/** Cached count of completed required courses (for n_of_m / complete_all dashboards). */
|
||||
completedCount: integer('completed_count').default(0).notNull(),
|
||||
/** Cached numerator for the requirement (e.g. courses required to complete). */
|
||||
requiredCount: integer('required_count').default(0).notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true, mode: 'string' }),
|
||||
lastEvaluatedAt: timestamp('last_evaluated_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.goalId],
|
||||
foreignColumns: [programGoal.id],
|
||||
name: 'program_goal_assignment_goal_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.programMemberId],
|
||||
foreignColumns: [programMember.id],
|
||||
name: 'program_goal_assignment_program_member_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
unique('program_goal_assignment_goal_id_program_member_id_unique').on(table.goalId, table.programMemberId),
|
||||
index('idx_program_goal_assignment_goal_id').on(table.goalId),
|
||||
index('idx_program_goal_assignment_program_member_id').on(table.programMemberId),
|
||||
index('idx_program_goal_assignment_due_date').on(table.dueDate)
|
||||
]
|
||||
);
|
||||
|
||||
// ─── AI Token Usage ──────────────────────────────────────────────────────────
|
||||
|
||||
export const aiTokenUsage = pgTable(
|
||||
@@ -2687,6 +3002,78 @@ export const aiCreditPurchase = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ─── AI Tutor Fair-Use ───────────────────────────────────────────────────────
|
||||
|
||||
export const aiTutorMessageCount = pgTable(
|
||||
'ai_tutor_message_count',
|
||||
{
|
||||
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
|
||||
name: 'ai_tutor_message_count_id_seq',
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
cache: 1
|
||||
}),
|
||||
orgId: uuid('org_id').notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
/** First day of the calendar month this counter covers. */
|
||||
periodStart: date('period_start').notNull(),
|
||||
messageCount: integer('message_count').notNull().default(0),
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true, mode: 'string' }),
|
||||
capHitAt: timestamp('cap_hit_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.orgId],
|
||||
foreignColumns: [organization.id],
|
||||
name: 'ai_tutor_message_count_org_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'ai_tutor_message_count_user_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
unique('ai_tutor_message_count_org_user_period_key').on(table.orgId, table.userId, table.periodStart),
|
||||
index('idx_ai_tutor_message_count_org_period').on(table.orgId, table.periodStart),
|
||||
index('idx_ai_tutor_message_count_user').on(table.userId, table.periodStart)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiTutorCapEvent = pgTable(
|
||||
'ai_tutor_cap_event',
|
||||
{
|
||||
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
|
||||
name: 'ai_tutor_cap_event_id_seq',
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
cache: 1
|
||||
}),
|
||||
orgId: uuid('org_id').notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
courseId: uuid('course_id'),
|
||||
/** 'cap_reached' | 'pool_exhausted' | 'tutor_disabled' */
|
||||
eventType: text('event_type').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.orgId],
|
||||
foreignColumns: [organization.id],
|
||||
name: 'ai_tutor_cap_event_org_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'ai_tutor_cap_event_user_id_fkey'
|
||||
}).onDelete('cascade'),
|
||||
index('idx_ai_tutor_cap_event_org_occurred').on(table.orgId, table.occurredAt),
|
||||
index('idx_ai_tutor_cap_event_user').on(table.userId, table.occurredAt)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiChatConversation = pgTable(
|
||||
'ai_chat_conversation',
|
||||
{
|
||||
@@ -2734,3 +3121,192 @@ export const aiChatDocument = pgTable(
|
||||
index('idx_ai_chat_document_conversation').on(table.conversationId)
|
||||
]
|
||||
);
|
||||
|
||||
// ─── BullMQ Job State (Postgres source of truth) ─────────────────────────────
|
||||
// User-visible status for queued/long-running domain runs. BullMQ holds the
|
||||
// operational job; these tables hold the entity the dashboard polls and
|
||||
// resumes against. See prd/notification-system and the bullmq plan.
|
||||
|
||||
export const jobStatus = pgEnum('JOB_STATUS', ['queued', 'running', 'completed', 'failed', 'canceled']);
|
||||
|
||||
/**
|
||||
* `media_job` — one user-facing media post-processing run (per uploaded asset).
|
||||
* The BullMQ FlowProducer orchestrates one or more attempts; this row stores
|
||||
* the latest status the dashboard polls.
|
||||
*/
|
||||
export const mediaJob = pgTable(
|
||||
'media_job',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id').notNull(),
|
||||
assetId: uuid('asset_id').notNull(),
|
||||
/** Snapshot of who started this run; never re-derived inside the worker. */
|
||||
triggeredByProfileId: uuid('triggered_by_profile_id'),
|
||||
status: jobStatus().default('queued').notNull(),
|
||||
stage: varchar({ length: 64 }),
|
||||
progressPercent: integer('progress_percent').default(0),
|
||||
/** Latest BullMQ root job id from the FlowProducer tree. Operational only. */
|
||||
rootJobId: text('root_job_id'),
|
||||
/** All BullMQ job ids for this run, keyed by job name (operational). */
|
||||
jobIds: jsonb('job_ids').default({}).$type<Record<string, string>>(),
|
||||
attempt: integer().default(0).notNull(),
|
||||
maxAttempts: integer('max_attempts').default(3).notNull(),
|
||||
cancelRequestedAt: timestamp('cancel_requested_at', { withTimezone: true, mode: 'string' }),
|
||||
/** Provider/cost data once we wire transcription billing. */
|
||||
costCents: integer('cost_cents').default(0),
|
||||
result: jsonb().$type<Record<string, unknown> | null>(),
|
||||
error: jsonb().$type<{ code: string; message: string; details?: unknown } | null>(),
|
||||
warnings: jsonb().default([]).$type<string[]>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organization.id],
|
||||
name: 'media_job_organization_id_fkey'
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.assetId],
|
||||
foreignColumns: [asset.id],
|
||||
name: 'media_job_asset_id_fkey'
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.triggeredByProfileId],
|
||||
foreignColumns: [profile.id],
|
||||
name: 'media_job_triggered_by_profile_id_fkey'
|
||||
}).onDelete('set null'),
|
||||
index('idx_media_job_asset_id').on(table.assetId),
|
||||
index('idx_media_job_org_status').on(table.organizationId, table.status),
|
||||
index('idx_media_job_updated_at').on(table.updatedAt)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* `job_step` — generic checkpoint ledger for any queued domain run. Workers
|
||||
* look up `(runId, stepKey)` first and short-circuit if the step has a result,
|
||||
* which makes BullMQ retries safe against partial completion.
|
||||
*/
|
||||
export const jobStep = pgTable(
|
||||
'job_step',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
/** The owning domain (e.g. `media`, `course-import`, `onboarding-bootstrap`). */
|
||||
domain: varchar({ length: 64 }).notNull(),
|
||||
/** Domain run id (e.g. `media_job.id`). */
|
||||
runId: uuid('run_id').notNull(),
|
||||
/** Stable key for this step (e.g. `probe-metadata`). */
|
||||
stepKey: varchar('step_key', { length: 96 }).notNull(),
|
||||
status: jobStatus().default('queued').notNull(),
|
||||
/** Hash of inputs to detect changed inputs across retries. */
|
||||
inputHash: text('input_hash'),
|
||||
/** Provider id (whisper job id, ffmpeg run id, etc.) for de-dup. */
|
||||
providerId: text('provider_id'),
|
||||
result: jsonb().$type<Record<string, unknown> | null>(),
|
||||
error: jsonb().$type<{ code: string; message: string; details?: unknown } | null>(),
|
||||
attempt: integer().default(0).notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }),
|
||||
finishedAt: timestamp('finished_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique('job_step_run_step_unique').on(table.domain, table.runId, table.stepKey),
|
||||
index('idx_job_step_run').on(table.domain, table.runId)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Whisper-derived transcript + captions artifact for an uploaded video asset.
|
||||
* One row per asset (re-transcribe replaces via upsert on asset_id).
|
||||
*/
|
||||
export const mediaTranscript = pgTable(
|
||||
'media_transcript',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id').notNull(),
|
||||
assetId: uuid('asset_id').notNull(),
|
||||
mediaJobId: uuid('media_job_id'),
|
||||
language: varchar({ length: 8 }).notNull(),
|
||||
provider: varchar({ length: 32 }).default('openai').notNull(),
|
||||
model: varchar({ length: 64 }).default('whisper-1').notNull(),
|
||||
text: text().notNull(),
|
||||
segments: jsonb().$type<{ start: number; end: number; text: string }[]>().notNull(),
|
||||
vttStorageKey: text('vtt_storage_key').notNull(),
|
||||
vttBucket: text('vtt_bucket').notNull(),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
costCents: integer('cost_cents').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organization.id],
|
||||
name: 'media_transcript_organization_id_fkey'
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.assetId],
|
||||
foreignColumns: [asset.id],
|
||||
name: 'media_transcript_asset_id_fkey'
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('cascade'),
|
||||
foreignKey({
|
||||
columns: [table.mediaJobId],
|
||||
foreignColumns: [mediaJob.id],
|
||||
name: 'media_transcript_media_job_id_fkey'
|
||||
})
|
||||
.onUpdate('cascade')
|
||||
.onDelete('set null'),
|
||||
unique('media_transcript_asset_id_unique').on(table.assetId),
|
||||
index('idx_media_transcript_asset').on(table.assetId)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* `dead_letter_job` — terminal failure record after BullMQ exhausts attempts
|
||||
* or a domain explicitly dead-letters a run. Drives the admin retry/replay UI
|
||||
* and Tinybird/Slack alerting.
|
||||
*/
|
||||
export const deadLetterJob = pgTable(
|
||||
'dead_letter_job',
|
||||
{
|
||||
id: uuid()
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
organizationId: uuid('organization_id'),
|
||||
domain: varchar({ length: 64 }).notNull(),
|
||||
runId: uuid('run_id'),
|
||||
queueName: varchar('queue_name', { length: 64 }).notNull(),
|
||||
jobName: varchar('job_name', { length: 96 }).notNull(),
|
||||
/** Original BullMQ job id. */
|
||||
bullmqJobId: text('bullmq_job_id'),
|
||||
payload: jsonb().$type<Record<string, unknown>>(),
|
||||
error: jsonb().$type<{ code: string; message: string; stack?: string }>(),
|
||||
attempts: integer().default(0).notNull(),
|
||||
/** Optional pointer to a replay job once an operator retries. */
|
||||
replayedAt: timestamp('replayed_at', { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow().notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('idx_dead_letter_job_domain_created').on(table.domain, table.createdAt),
|
||||
index('idx_dead_letter_job_org_created').on(table.organizationId, table.createdAt)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { seedAccount } from '@db/utils/seed/account';
|
||||
import { seedCompliance } from '@db/utils/seed/compliance';
|
||||
import { MVC_SECTION_ID, PANDAS_SECTION_ID, REACT_SECTION_ID, seedCourseSections } from '@db/utils/seed/courseSection';
|
||||
import { seedCourses } from '@db/utils/seed/course';
|
||||
import { seedExercise } from '@db/utils/seed/exercise';
|
||||
@@ -79,6 +80,7 @@ Flags:
|
||||
--exercises Seed exercises
|
||||
--questions Seed questions
|
||||
--templates Seed exercise templates
|
||||
--compliance Seed compliance demo data (coursera-test org)
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
@@ -216,6 +218,10 @@ const seedFunctions = {
|
||||
templates: async () => {
|
||||
console.log('📝 Seeding exercise templates...');
|
||||
await seedExerciseTemplates();
|
||||
},
|
||||
compliance: async () => {
|
||||
console.log('📝 Seeding compliance demo data (coursera-test)...');
|
||||
await seedCompliance({ enterpriseOrgId: ENTERPRISE_ORG_ID });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,6 +259,7 @@ async function seed() {
|
||||
await seedFunctions.exercises();
|
||||
await seedFunctions.questions();
|
||||
await seedFunctions.templates();
|
||||
await seedFunctions.compliance();
|
||||
} else {
|
||||
// Run only specified seed functions
|
||||
// Order matters for dependencies, so we maintain the original order
|
||||
@@ -273,7 +280,8 @@ async function seed() {
|
||||
'lessons',
|
||||
'exercises',
|
||||
'questions',
|
||||
'templates'
|
||||
'templates',
|
||||
'compliance'
|
||||
];
|
||||
|
||||
for (const seedName of orderedSeeds) {
|
||||
|
||||
@@ -213,3 +213,19 @@ export type TCourseImportDraftStatus = (typeof schema.courseImportDraftStatus.en
|
||||
export type TWidgetStatus = (typeof schema.widgetStatus.enumValues)[number];
|
||||
export type TWidgetLayoutType = (typeof schema.widgetLayoutType.enumValues)[number];
|
||||
export type TWidgetSelectionMode = (typeof schema.widgetSelectionMode.enumValues)[number];
|
||||
|
||||
// ─── Job state ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type TJobStatus = (typeof schema.jobStatus.enumValues)[number];
|
||||
|
||||
export type TMediaJob = typeof schema.mediaJob.$inferSelect;
|
||||
export type TNewMediaJob = typeof schema.mediaJob.$inferInsert;
|
||||
|
||||
export type TMediaTranscript = typeof schema.mediaTranscript.$inferSelect;
|
||||
export type TNewMediaTranscript = typeof schema.mediaTranscript.$inferInsert;
|
||||
|
||||
export type TJobStep = typeof schema.jobStep.$inferSelect;
|
||||
export type TNewJobStep = typeof schema.jobStep.$inferInsert;
|
||||
|
||||
export type TDeadLetterJob = typeof schema.deadLetterJob.$inferSelect;
|
||||
export type TNewDeadLetterJob = typeof schema.deadLetterJob.$inferInsert;
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
import {
|
||||
and,
|
||||
course,
|
||||
courseCompletionRecord,
|
||||
courseSection,
|
||||
db,
|
||||
eq,
|
||||
exercise,
|
||||
group,
|
||||
groupmember,
|
||||
inArray,
|
||||
lesson,
|
||||
organizationmember,
|
||||
profile,
|
||||
user
|
||||
} from '@db/drizzle';
|
||||
|
||||
import type {
|
||||
TNewCourse,
|
||||
TNewCourseCompletionRecord,
|
||||
TNewCourseSection,
|
||||
TNewExercise,
|
||||
TNewGroup,
|
||||
TNewGroupmember,
|
||||
TNewLesson,
|
||||
TNewProfile
|
||||
} from '@db/types';
|
||||
|
||||
interface SeedComplianceArgs {
|
||||
enterpriseOrgId: string;
|
||||
}
|
||||
|
||||
// ---------- Fixed IDs (stable across runs) ----------
|
||||
|
||||
const HIPAA_GROUP_ID = '7e000000-1000-4000-8000-000000000001';
|
||||
const SOC2_GROUP_ID = '7e000000-1000-4000-8000-000000000002';
|
||||
|
||||
const HIPAA_COURSE_ID = '7e000000-2000-4000-8000-000000000001';
|
||||
const SOC2_COURSE_ID = '7e000000-2000-4000-8000-000000000002';
|
||||
|
||||
const HIPAA_SECTION_ID = '7e000000-3000-4000-8000-000000000001';
|
||||
const SOC2_SECTION_ID = '7e000000-3000-4000-8000-000000000002';
|
||||
|
||||
const HIPAA_LESSON_IDS = ['7e000000-4000-4000-8000-000000000001', '7e000000-4000-4000-8000-000000000002'] as const;
|
||||
const SOC2_LESSON_IDS = ['7e000000-4000-4000-8000-000000000003', '7e000000-4000-4000-8000-000000000004'] as const;
|
||||
|
||||
const HIPAA_EXERCISE_ID = '7e000000-5000-4000-8000-000000000001';
|
||||
const SOC2_EXERCISE_ID = '7e000000-5000-4000-8000-000000000002';
|
||||
|
||||
// 20 stable learner profile UUIDs
|
||||
const LEARNER_PROFILES: Array<{ id: string; fullname: string; username: string; email: string; avatarUrl: string }> = [
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000001',
|
||||
fullname: 'Ada Lovelace',
|
||||
username: 'ada.lovelace',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000002',
|
||||
fullname: 'Grace Hopper',
|
||||
username: 'grace.hopper',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000003',
|
||||
fullname: 'Linus Torvalds',
|
||||
username: 'linus.torvalds',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000004',
|
||||
fullname: 'Margaret Hamilton',
|
||||
username: 'margaret.hamilton',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000005',
|
||||
fullname: 'Alan Turing',
|
||||
username: 'alan.turing',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000006',
|
||||
fullname: 'Katherine Johnson',
|
||||
username: 'katherine.johnson',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000007',
|
||||
fullname: 'Dennis Ritchie',
|
||||
username: 'dennis.ritchie',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000008',
|
||||
fullname: 'Barbara Liskov',
|
||||
username: 'barbara.liskov',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000009',
|
||||
fullname: 'Donald Knuth',
|
||||
username: 'donald.knuth',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000010',
|
||||
fullname: 'Radia Perlman',
|
||||
username: 'radia.perlman',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000011',
|
||||
fullname: 'Tim Berners-Lee',
|
||||
username: 'tim.bernerslee',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000012',
|
||||
fullname: 'Sophie Wilson',
|
||||
username: 'sophie.wilson',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000013',
|
||||
fullname: 'Anita Borg',
|
||||
username: 'anita.borg',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000014',
|
||||
fullname: 'Brendan Eich',
|
||||
username: 'brendan.eich',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000015',
|
||||
fullname: 'Jeannette Wing',
|
||||
username: 'jeannette.wing',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000016',
|
||||
fullname: 'Bjarne Stroustrup',
|
||||
username: 'bjarne.stroustrup',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000017',
|
||||
fullname: 'Hedy Lamarr',
|
||||
username: 'hedy.lamarr',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000018',
|
||||
fullname: 'James Gosling',
|
||||
username: 'james.gosling',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000019',
|
||||
fullname: 'Frances Allen',
|
||||
username: 'frances.allen',
|
||||
email: '[email protected]'
|
||||
},
|
||||
{
|
||||
id: '7e000000-9000-4000-8000-000000000020',
|
||||
fullname: 'Vint Cerf',
|
||||
username: 'vint.cerf',
|
||||
email: '[email protected]'
|
||||
}
|
||||
].map((p) => ({
|
||||
...p,
|
||||
avatarUrl: `https://api.dicebear.com/9.x/notionists/svg?seed=${encodeURIComponent(p.username)}`
|
||||
}));
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isoDaysFromNow(now: Date, days: number): string {
|
||||
return new Date(now.getTime() + days * DAY_MS).toISOString();
|
||||
}
|
||||
|
||||
type RecordPlan = {
|
||||
status:
|
||||
| 'compliant'
|
||||
| 'expiring_soon'
|
||||
| 'in_grace_period'
|
||||
| 'non_compliant'
|
||||
| 'in_progress'
|
||||
| 'not_started'
|
||||
| 'waived'
|
||||
| 'no_record';
|
||||
};
|
||||
|
||||
// Per-course learner-index → status plan. Indices reference LEARNER_PROFILES (0..19).
|
||||
const HIPAA_STATUS_PLAN: ReadonlyArray<RecordPlan['status']> = [
|
||||
// 9 compliant
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
// 2 expiring_soon
|
||||
'expiring_soon',
|
||||
'expiring_soon',
|
||||
// 1 in_grace_period
|
||||
'in_grace_period',
|
||||
// 2 non_compliant
|
||||
'non_compliant',
|
||||
'non_compliant',
|
||||
// 2 in_progress
|
||||
'in_progress',
|
||||
'in_progress',
|
||||
// 2 not_started
|
||||
'not_started',
|
||||
'not_started',
|
||||
// 1 waived
|
||||
'waived',
|
||||
// 1 no_record (no row inserted)
|
||||
'no_record'
|
||||
];
|
||||
|
||||
const SOC2_STATUS_PLAN: ReadonlyArray<RecordPlan['status']> = [
|
||||
// 7 compliant
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
'compliant',
|
||||
// 2 expiring_soon
|
||||
'expiring_soon',
|
||||
'expiring_soon',
|
||||
// 2 in_grace_period
|
||||
'in_grace_period',
|
||||
'in_grace_period',
|
||||
// 3 non_compliant
|
||||
'non_compliant',
|
||||
'non_compliant',
|
||||
'non_compliant',
|
||||
// 2 in_progress
|
||||
'in_progress',
|
||||
'in_progress',
|
||||
// 2 not_started
|
||||
'not_started',
|
||||
'not_started',
|
||||
// 1 waived
|
||||
'waived',
|
||||
// 1 no_record
|
||||
'no_record'
|
||||
];
|
||||
|
||||
function buildCompletionRecord({
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
status,
|
||||
now,
|
||||
retakeIntervalMonths,
|
||||
waiverAdminProfileId
|
||||
}: {
|
||||
courseId: string;
|
||||
groupMemberId: string;
|
||||
profileId: string;
|
||||
status: RecordPlan['status'];
|
||||
now: Date;
|
||||
retakeIntervalMonths: number;
|
||||
waiverAdminProfileId: string | null;
|
||||
}): TNewCourseCompletionRecord | null {
|
||||
if (status === 'no_record') return null;
|
||||
|
||||
const validityDays = retakeIntervalMonths * 30;
|
||||
|
||||
switch (status) {
|
||||
case 'compliant': {
|
||||
// Completed ~ between (validityDays - 90) and (validityDays - 30) days ago,
|
||||
// so validUntil is well in the future (>30 days away).
|
||||
const completedDaysAgo = Math.max(30, Math.floor(validityDays / 3));
|
||||
const completedAt = isoDaysFromNow(now, -completedDaysAgo);
|
||||
const validUntil = isoDaysFromNow(now, validityDays - completedDaysAgo);
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: completedAt,
|
||||
startedAt: isoDaysFromNow(now, -completedDaysAgo - 2),
|
||||
completedAt,
|
||||
validUntil,
|
||||
score: 88,
|
||||
attempts: 1,
|
||||
timeSpentMinutes: 42
|
||||
};
|
||||
}
|
||||
case 'expiring_soon': {
|
||||
// completedAt ~ (validityDays - 7) days ago → validUntil 7 days from now.
|
||||
const completedDaysAgo = validityDays - 7;
|
||||
const completedAt = isoDaysFromNow(now, -completedDaysAgo);
|
||||
const validUntil = isoDaysFromNow(now, 7);
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: completedAt,
|
||||
startedAt: isoDaysFromNow(now, -completedDaysAgo - 1),
|
||||
completedAt,
|
||||
validUntil,
|
||||
score: 91,
|
||||
attempts: 1,
|
||||
timeSpentMinutes: 38
|
||||
};
|
||||
}
|
||||
case 'in_grace_period': {
|
||||
// dueDate 3 days in the past, never completed.
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: isoDaysFromNow(now, -3),
|
||||
startedAt: isoDaysFromNow(now, -10),
|
||||
attempts: 0
|
||||
};
|
||||
}
|
||||
case 'non_compliant': {
|
||||
// dueDate 30 days in the past, never completed.
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: isoDaysFromNow(now, -30),
|
||||
startedAt: isoDaysFromNow(now, -20),
|
||||
attempts: 0
|
||||
};
|
||||
}
|
||||
case 'in_progress': {
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: isoDaysFromNow(now, 21),
|
||||
startedAt: isoDaysFromNow(now, -2),
|
||||
attempts: 0,
|
||||
timeSpentMinutes: 12
|
||||
};
|
||||
}
|
||||
case 'not_started': {
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: isoDaysFromNow(now, 21),
|
||||
attempts: 0
|
||||
};
|
||||
}
|
||||
case 'waived': {
|
||||
return {
|
||||
courseId,
|
||||
groupMemberId,
|
||||
profileId,
|
||||
cycleNumber: 1,
|
||||
status,
|
||||
dueDate: isoDaysFromNow(now, 14),
|
||||
waivedBy: waiverAdminProfileId ?? profileId,
|
||||
waiverReason: 'On approved leave through end of cycle',
|
||||
waiverExpiresAt: isoDaysFromNow(now, 90)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Main seed ----------
|
||||
|
||||
export async function seedCompliance({ enterpriseOrgId }: SeedComplianceArgs) {
|
||||
const now = new Date();
|
||||
|
||||
// 1a. Users (profile.id FKs to user.id)
|
||||
const profileIds = LEARNER_PROFILES.map((p) => p.id);
|
||||
const existingUsers = await db.select({ id: user.id }).from(user).where(inArray(user.id, profileIds));
|
||||
const existingUserIds = new Set(existingUsers.map((u) => u.id));
|
||||
const usersToInsert = LEARNER_PROFILES.filter((p) => !existingUserIds.has(p.id)).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.fullname,
|
||||
email: p.email,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: null,
|
||||
banned: false,
|
||||
isAnonymous: false
|
||||
}));
|
||||
if (usersToInsert.length > 0) {
|
||||
await db.insert(user).values(usersToInsert);
|
||||
console.log(` ✓ Inserted ${usersToInsert.length} learner user(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Learner users already exist, skipping');
|
||||
}
|
||||
|
||||
// 1b. Profiles
|
||||
const existingProfiles = await db.select({ id: profile.id }).from(profile).where(inArray(profile.id, profileIds));
|
||||
const existingProfileIds = new Set(existingProfiles.map((p) => p.id));
|
||||
const profilesToInsert: TNewProfile[] = LEARNER_PROFILES.filter((p) => !existingProfileIds.has(p.id)).map((p) => ({
|
||||
id: p.id,
|
||||
fullname: p.fullname,
|
||||
username: p.username,
|
||||
email: p.email,
|
||||
avatarUrl: p.avatarUrl,
|
||||
canAddCourse: false,
|
||||
isEmailVerified: true
|
||||
}));
|
||||
if (profilesToInsert.length > 0) {
|
||||
await db.insert(profile).values(profilesToInsert);
|
||||
console.log(` ✓ Inserted ${profilesToInsert.length} learner profile(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Learner profiles already exist, skipping');
|
||||
}
|
||||
|
||||
// 2. Groups
|
||||
const existingGroups = await db
|
||||
.select({ id: group.id })
|
||||
.from(group)
|
||||
.where(inArray(group.id, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||||
const existingGroupIds = new Set(existingGroups.map((g) => g.id));
|
||||
const groupsToInsert: TNewGroup[] = [
|
||||
{
|
||||
id: HIPAA_GROUP_ID,
|
||||
name: 'HIPAA Awareness 2026',
|
||||
description: 'Mandatory annual HIPAA awareness refresher for all Coursera Test staff handling PHI.',
|
||||
organizationId: enterpriseOrgId
|
||||
},
|
||||
{
|
||||
id: SOC2_GROUP_ID,
|
||||
name: 'SOC 2 Security Basics',
|
||||
description: 'Twice-yearly SOC 2 security fundamentals — required for engineering and operations.',
|
||||
organizationId: enterpriseOrgId
|
||||
}
|
||||
].filter((g) => !existingGroupIds.has(g.id));
|
||||
if (groupsToInsert.length > 0) {
|
||||
await db.insert(group).values(groupsToInsert);
|
||||
console.log(` ✓ Inserted ${groupsToInsert.length} compliance group(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance groups already exist, skipping');
|
||||
}
|
||||
|
||||
// 3. Courses (COMPLIANCE type)
|
||||
const existingCourses = await db
|
||||
.select({ id: course.id })
|
||||
.from(course)
|
||||
.where(inArray(course.id, [HIPAA_COURSE_ID, SOC2_COURSE_ID]));
|
||||
const existingCourseIds = new Set(existingCourses.map((c) => c.id));
|
||||
const coursesToInsert: TNewCourse[] = [
|
||||
{
|
||||
id: HIPAA_COURSE_ID,
|
||||
title: 'HIPAA Awareness 2026',
|
||||
description:
|
||||
'Annual HIPAA refresher covering PHI handling, breach reporting, and minimum-necessary access for everyone who touches patient data.',
|
||||
overview: '<p>Annual HIPAA refresher covering PHI handling, breach reporting, and minimum-necessary access.</p>',
|
||||
groupId: HIPAA_GROUP_ID,
|
||||
isTemplate: false,
|
||||
logo: 'https://images.unsplash.com/photo-1551601651-2a8555f1a136?auto=format&fit=crop&w=600&q=70',
|
||||
slug: 'hipaa-awareness-2026',
|
||||
metadata: {
|
||||
goals: '',
|
||||
grading: false,
|
||||
description: '',
|
||||
requirements: '',
|
||||
lessonDownload: false,
|
||||
allowNewStudent: false
|
||||
},
|
||||
cost: 0,
|
||||
currency: 'USD',
|
||||
isPublished: true,
|
||||
certificate: { isDownloadable: true, theme: 'professional' },
|
||||
compliance: {
|
||||
retakeIntervalMonths: 12,
|
||||
gracePeriodDays: 14,
|
||||
reminderDaysBefore: [30, 14, 7, 1],
|
||||
isMandatory: true,
|
||||
framework: 'HIPAA',
|
||||
passingScore: 80
|
||||
},
|
||||
status: 'ACTIVE',
|
||||
type: 'COMPLIANCE'
|
||||
},
|
||||
{
|
||||
id: SOC2_COURSE_ID,
|
||||
title: 'SOC 2 Security Basics',
|
||||
description:
|
||||
'Core SOC 2 controls — password hygiene, phishing, data classification, incident response. Runs every six months for engineering and operations.',
|
||||
overview: '<p>Twice-yearly SOC 2 security fundamentals. Required for engineering, IT, and operations.</p>',
|
||||
groupId: SOC2_GROUP_ID,
|
||||
isTemplate: false,
|
||||
logo: 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?auto=format&fit=crop&w=600&q=70',
|
||||
slug: 'soc2-security-basics',
|
||||
metadata: {
|
||||
goals: '',
|
||||
grading: true,
|
||||
description: '',
|
||||
requirements: '',
|
||||
lessonDownload: false,
|
||||
allowNewStudent: false
|
||||
},
|
||||
cost: 0,
|
||||
currency: 'USD',
|
||||
isPublished: true,
|
||||
certificate: { isDownloadable: true, theme: 'professional' },
|
||||
compliance: {
|
||||
retakeIntervalMonths: 6,
|
||||
gracePeriodDays: 7,
|
||||
reminderDaysBefore: [14, 7, 1],
|
||||
isMandatory: true,
|
||||
framework: 'ISO',
|
||||
passingScore: 85
|
||||
},
|
||||
status: 'ACTIVE',
|
||||
type: 'COMPLIANCE'
|
||||
}
|
||||
].filter((c) => !existingCourseIds.has(c.id!));
|
||||
if (coursesToInsert.length > 0) {
|
||||
await db.insert(course).values(coursesToInsert);
|
||||
console.log(` ✓ Inserted ${coursesToInsert.length} compliance course(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance courses already exist, skipping');
|
||||
}
|
||||
|
||||
// 4. Sections + lessons + exercises
|
||||
const sectionsToInsert: TNewCourseSection[] = [
|
||||
{ id: HIPAA_SECTION_ID, courseId: HIPAA_COURSE_ID, title: 'Compliance Module', order: 0 },
|
||||
{ id: SOC2_SECTION_ID, courseId: SOC2_COURSE_ID, title: 'Security Fundamentals', order: 0 }
|
||||
];
|
||||
const existingSections = await db
|
||||
.select({ id: courseSection.id })
|
||||
.from(courseSection)
|
||||
.where(inArray(courseSection.id, [HIPAA_SECTION_ID, SOC2_SECTION_ID]));
|
||||
const existingSectionIds = new Set(existingSections.map((s) => s.id));
|
||||
const newSections = sectionsToInsert.filter((s) => !existingSectionIds.has(s.id!));
|
||||
if (newSections.length > 0) {
|
||||
await db.insert(courseSection).values(newSections);
|
||||
console.log(` ✓ Inserted ${newSections.length} compliance section(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance sections already exist, skipping');
|
||||
}
|
||||
|
||||
const lessonsPlan: TNewLesson[] = [
|
||||
{
|
||||
id: HIPAA_LESSON_IDS[0],
|
||||
courseId: HIPAA_COURSE_ID,
|
||||
sectionId: HIPAA_SECTION_ID,
|
||||
title: 'What HIPAA actually requires of you',
|
||||
note: '<p>Plain-English summary of the privacy rule, security rule, and breach notification obligations.</p>',
|
||||
isUnlocked: true
|
||||
},
|
||||
{
|
||||
id: HIPAA_LESSON_IDS[1],
|
||||
courseId: HIPAA_COURSE_ID,
|
||||
sectionId: HIPAA_SECTION_ID,
|
||||
title: 'Handling PHI: the everyday rules',
|
||||
note: '<p>Minimum necessary access, secure messaging, faxing rules, and what to do if you spot a breach.</p>',
|
||||
isUnlocked: false
|
||||
},
|
||||
{
|
||||
id: SOC2_LESSON_IDS[0],
|
||||
courseId: SOC2_COURSE_ID,
|
||||
sectionId: SOC2_SECTION_ID,
|
||||
title: 'Passwords, MFA, and not getting phished',
|
||||
note: '<p>Strong-credential hygiene + recognizing the four most common phishing patterns we see at Coursera Test.</p>',
|
||||
isUnlocked: true
|
||||
},
|
||||
{
|
||||
id: SOC2_LESSON_IDS[1],
|
||||
courseId: SOC2_COURSE_ID,
|
||||
sectionId: SOC2_SECTION_ID,
|
||||
title: 'Data classification & incident response',
|
||||
note: '<p>What counts as confidential, who to call when something goes wrong, and how we record an incident.</p>',
|
||||
isUnlocked: false
|
||||
}
|
||||
];
|
||||
const existingLessons = await db
|
||||
.select({ id: lesson.id })
|
||||
.from(lesson)
|
||||
.where(
|
||||
inArray(
|
||||
lesson.id,
|
||||
lessonsPlan.map((l) => l.id!)
|
||||
)
|
||||
);
|
||||
const existingLessonIds = new Set(existingLessons.map((l) => l.id));
|
||||
const newLessons = lessonsPlan.filter((l) => !existingLessonIds.has(l.id!));
|
||||
if (newLessons.length > 0) {
|
||||
await db.insert(lesson).values(newLessons);
|
||||
console.log(` ✓ Inserted ${newLessons.length} compliance lesson(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance lessons already exist, skipping');
|
||||
}
|
||||
|
||||
const exercisesPlan: TNewExercise[] = [
|
||||
{
|
||||
id: HIPAA_EXERCISE_ID,
|
||||
title: 'HIPAA: end-of-course quiz',
|
||||
description: '<p>Pass this short quiz to mark the course complete.</p>',
|
||||
lessonId: HIPAA_LESSON_IDS[1],
|
||||
sectionId: HIPAA_SECTION_ID
|
||||
},
|
||||
{
|
||||
id: SOC2_EXERCISE_ID,
|
||||
title: 'SOC 2: end-of-course quiz',
|
||||
description: '<p>Pass this short quiz to mark the course complete.</p>',
|
||||
lessonId: SOC2_LESSON_IDS[1],
|
||||
sectionId: SOC2_SECTION_ID
|
||||
}
|
||||
];
|
||||
const existingExercises = await db
|
||||
.select({ id: exercise.id })
|
||||
.from(exercise)
|
||||
.where(inArray(exercise.id, [HIPAA_EXERCISE_ID, SOC2_EXERCISE_ID]));
|
||||
const existingExerciseIds = new Set(existingExercises.map((e) => e.id));
|
||||
const newExercises = exercisesPlan.filter((e) => !existingExerciseIds.has(e.id!));
|
||||
if (newExercises.length > 0) {
|
||||
await db.insert(exercise).values(newExercises);
|
||||
console.log(` ✓ Inserted ${newExercises.length} compliance exercise(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance exercises already exist, skipping');
|
||||
}
|
||||
|
||||
// 5. Groupmembers (20 learners × 2 groups)
|
||||
const desiredMembers: Array<{ groupId: string; profileId: string }> = [];
|
||||
for (const groupId of [HIPAA_GROUP_ID, SOC2_GROUP_ID]) {
|
||||
for (const p of LEARNER_PROFILES) {
|
||||
desiredMembers.push({ groupId, profileId: p.id });
|
||||
}
|
||||
}
|
||||
const existingGroupMembers = await db
|
||||
.select({ id: groupmember.id, groupId: groupmember.groupId, profileId: groupmember.profileId })
|
||||
.from(groupmember)
|
||||
.where(inArray(groupmember.groupId, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||||
const existingMemberKey = new Set(existingGroupMembers.map((m) => `${m.groupId}-${m.profileId}`));
|
||||
const newMembers: TNewGroupmember[] = desiredMembers
|
||||
.filter((m) => !existingMemberKey.has(`${m.groupId}-${m.profileId}`))
|
||||
.map((m) => ({
|
||||
groupId: m.groupId,
|
||||
profileId: m.profileId,
|
||||
roleId: 3 // STUDENT
|
||||
}));
|
||||
if (newMembers.length > 0) {
|
||||
await db.insert(groupmember).values(newMembers);
|
||||
console.log(` ✓ Inserted ${newMembers.length} compliance groupmember(s)`);
|
||||
} else {
|
||||
console.log(' ✓ Compliance groupmembers already exist, skipping');
|
||||
}
|
||||
|
||||
// Refetch groupmembers so we can look up the groupMember.id for each (groupId, profileId)
|
||||
const allMembers = await db
|
||||
.select({ id: groupmember.id, groupId: groupmember.groupId, profileId: groupmember.profileId })
|
||||
.from(groupmember)
|
||||
.where(inArray(groupmember.groupId, [HIPAA_GROUP_ID, SOC2_GROUP_ID]));
|
||||
const memberLookup = new Map<string, string>();
|
||||
for (const m of allMembers) {
|
||||
if (m.profileId) memberLookup.set(`${m.groupId}-${m.profileId}`, m.id);
|
||||
}
|
||||
|
||||
// 6. Resolve a waiver-admin profileId (the org's first admin)
|
||||
const orgAdmins = await db
|
||||
.select({ profileId: organizationmember.profileId })
|
||||
.from(organizationmember)
|
||||
.where(and(eq(organizationmember.organizationId, enterpriseOrgId), eq(organizationmember.roleId, 1)));
|
||||
const waiverAdminProfileId = orgAdmins.find((a) => a.profileId)?.profileId ?? null;
|
||||
|
||||
// 7. course_completion_record rows
|
||||
const recordsPlan: Array<{
|
||||
courseId: string;
|
||||
status: RecordPlan['status'];
|
||||
profileIdx: number;
|
||||
retakeIntervalMonths: number;
|
||||
}> = [];
|
||||
HIPAA_STATUS_PLAN.forEach((status, idx) =>
|
||||
recordsPlan.push({ courseId: HIPAA_COURSE_ID, status, profileIdx: idx, retakeIntervalMonths: 12 })
|
||||
);
|
||||
SOC2_STATUS_PLAN.forEach((status, idx) =>
|
||||
recordsPlan.push({ courseId: SOC2_COURSE_ID, status, profileIdx: idx, retakeIntervalMonths: 6 })
|
||||
);
|
||||
|
||||
const existingRecords = await db
|
||||
.select({
|
||||
courseId: courseCompletionRecord.courseId,
|
||||
profileId: courseCompletionRecord.profileId,
|
||||
cycleNumber: courseCompletionRecord.cycleNumber
|
||||
})
|
||||
.from(courseCompletionRecord)
|
||||
.where(inArray(courseCompletionRecord.courseId, [HIPAA_COURSE_ID, SOC2_COURSE_ID]));
|
||||
const existingRecordKey = new Set(existingRecords.map((r) => `${r.courseId}-${r.profileId}-${r.cycleNumber}`));
|
||||
|
||||
const recordsToInsert: TNewCourseCompletionRecord[] = [];
|
||||
for (const plan of recordsPlan) {
|
||||
const learner = LEARNER_PROFILES[plan.profileIdx];
|
||||
if (!learner) continue;
|
||||
|
||||
const groupId = plan.courseId === HIPAA_COURSE_ID ? HIPAA_GROUP_ID : SOC2_GROUP_ID;
|
||||
const groupMemberId = memberLookup.get(`${groupId}-${learner.id}`);
|
||||
if (!groupMemberId) continue;
|
||||
|
||||
const key = `${plan.courseId}-${learner.id}-1`;
|
||||
if (existingRecordKey.has(key)) continue;
|
||||
|
||||
const row = buildCompletionRecord({
|
||||
courseId: plan.courseId,
|
||||
groupMemberId,
|
||||
profileId: learner.id,
|
||||
status: plan.status,
|
||||
now,
|
||||
retakeIntervalMonths: plan.retakeIntervalMonths,
|
||||
waiverAdminProfileId
|
||||
});
|
||||
if (row) recordsToInsert.push(row);
|
||||
}
|
||||
|
||||
if (recordsToInsert.length > 0) {
|
||||
await db.insert(courseCompletionRecord).values(recordsToInsert);
|
||||
console.log(` ✓ Inserted ${recordsToInsert.length} course_completion_record row(s)`);
|
||||
} else {
|
||||
console.log(' ✓ course_completion_record rows already exist, skipping');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user