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
+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';