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

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