feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
STUDENT_TUTOR_APPROACHING_THRESHOLD,
|
||||
STUDENT_TUTOR_MONTHLY_CAP,
|
||||
type AiTutorSettings
|
||||
} from '@cio/ai-assistant/tutor-config';
|
||||
import {
|
||||
getCapStatusSummary,
|
||||
getLearnerCapEvents,
|
||||
getLearnerCourseBreakdown,
|
||||
getLearnerDailyUsage,
|
||||
getLearnerLeaderboard,
|
||||
getMonthlyTutorCount,
|
||||
incrementMonthlyTutorCount,
|
||||
recordCapEvent
|
||||
} from '@cio/db/queries/agent';
|
||||
|
||||
import { AppError } from '@api/utils/errors';
|
||||
import { getEffectiveAiTutorSettings } from './tutor-config';
|
||||
import { enforceTokenBalance } from './usage';
|
||||
|
||||
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
|
||||
|
||||
/**
|
||||
* Whether per-learner cap enforcement is on for this environment.
|
||||
* Phase 1 ships with this off (instrumentation only); Phase 3 flips it on.
|
||||
*/
|
||||
export function isCapEnforced(): boolean {
|
||||
const raw = process.env.AI_TUTOR_CAP_ENFORCED;
|
||||
if (raw == null) return false;
|
||||
return raw === '1' || raw.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function startOfCurrentMonthUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function startOfPreviousMonthUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
function startOfLast90DaysUtc(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export function resolveTutorPeriod(period: 'current' | 'previous' | 'last90'): { start: Date; end?: Date } {
|
||||
if (period === 'previous') {
|
||||
const start = startOfPreviousMonthUtc();
|
||||
const end = startOfCurrentMonthUtc();
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
if (period === 'last90') {
|
||||
return { start: startOfLast90DaysUtc() };
|
||||
}
|
||||
|
||||
return { start: startOfCurrentMonthUtc() };
|
||||
}
|
||||
|
||||
export interface TutorPolicyResult {
|
||||
settings: AiTutorSettings;
|
||||
messageCount: number;
|
||||
capRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order of checks (matches PRD §"Enforcement"):
|
||||
* 1. Workspace AI tutor toggle (settings.enabled)
|
||||
* 2. Org pooled quota (enforceTokenBalance)
|
||||
* 3. Per-learner monthly cap (STUDENT_TUTOR_MONTHLY_CAP)
|
||||
*
|
||||
* Each failure logs a cap event so admins can see why a learner was blocked.
|
||||
* Cap enforcement is gated behind `AI_TUTOR_CAP_ENFORCED` for Phase 1 rollout.
|
||||
*/
|
||||
export async function enforceStudentTutorPolicy(
|
||||
orgId: string,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<TutorPolicyResult> {
|
||||
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
|
||||
|
||||
if (!settings.enabled) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'tutor_disabled' });
|
||||
throw new AppError('AI tutor is disabled for this workspace', 'AI_TUTOR_DISABLED', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await enforceTokenBalance(orgId);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError && error.code === 'TOKEN_LIMIT_REACHED') {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'pool_exhausted' });
|
||||
throw new AppError('AI tutor is taking a break', 'POOL_EXHAUSTED', 429);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
|
||||
|
||||
if (isCapEnforced() && monthly.messageCount >= STUDENT_TUTOR_CAP) {
|
||||
if (!monthly.capHitAt) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
|
||||
}
|
||||
throw new AppError('AI tutor is taking a break', 'LEARNER_CAP_REACHED', 429);
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
messageCount: monthly.messageCount,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount)
|
||||
};
|
||||
}
|
||||
|
||||
export async function incrementStudentTutorCount(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
courseId: string
|
||||
): Promise<{ messageCount: number; capHit: boolean }> {
|
||||
const result = await incrementMonthlyTutorCount({
|
||||
orgId,
|
||||
userId,
|
||||
periodStart: startOfCurrentMonthUtc(),
|
||||
cap: STUDENT_TUTOR_CAP
|
||||
});
|
||||
|
||||
if (result.capHit && isCapEnforced()) {
|
||||
await recordCapEvent({ orgId, userId, courseId, eventType: 'cap_reached' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getStudentTutorStatus(
|
||||
orgId: string,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<{ enabled: boolean; cap: number; capRemaining: number; enforced: boolean }> {
|
||||
const settings = await getEffectiveAiTutorSettings(orgId, courseId);
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, startOfCurrentMonthUtc());
|
||||
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
cap: STUDENT_TUTOR_CAP,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
|
||||
enforced: isCapEnforced()
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Admin reads (Fair-Use leaderboard + summary + per-learner detail) ───────
|
||||
|
||||
export interface AdminLeaderboardEntry {
|
||||
userId: string;
|
||||
fullname: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
messageCount: number;
|
||||
tokens: number;
|
||||
capRemaining: number;
|
||||
capPct: number;
|
||||
status: 'under' | 'approaching' | 'at_cap';
|
||||
capHitAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
}
|
||||
|
||||
export async function getTutorLearnerLeaderboard(
|
||||
orgId: string,
|
||||
params: {
|
||||
period: 'current' | 'previous' | 'last90';
|
||||
search?: string;
|
||||
sort?: 'messages' | 'tokens' | 'capPct';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<{ entries: AdminLeaderboardEntry[]; total: number; cap: number; periodStart: string }> {
|
||||
const { start, end } = resolveTutorPeriod(params.period);
|
||||
const page = params.page ?? 1;
|
||||
const limit = params.limit ?? 20;
|
||||
|
||||
const { rows, total } = await getLearnerLeaderboard(orgId, {
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
search: params.search,
|
||||
sort: params.sort,
|
||||
limit,
|
||||
offset: (page - 1) * limit
|
||||
});
|
||||
|
||||
const entries: AdminLeaderboardEntry[] = rows.map((row) => {
|
||||
const messageCount = row.messageCount;
|
||||
const capPct = messageCount / STUDENT_TUTOR_CAP;
|
||||
const status: AdminLeaderboardEntry['status'] =
|
||||
messageCount >= STUDENT_TUTOR_CAP
|
||||
? 'at_cap'
|
||||
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
|
||||
? 'approaching'
|
||||
: 'under';
|
||||
|
||||
return {
|
||||
userId: row.userId,
|
||||
fullname: row.fullname,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl,
|
||||
messageCount,
|
||||
tokens: row.tokens,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - messageCount),
|
||||
capPct,
|
||||
status,
|
||||
capHitAt: row.capHitAt,
|
||||
lastMessageAt: row.lastMessageAt
|
||||
};
|
||||
});
|
||||
|
||||
return { entries, total, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
|
||||
}
|
||||
|
||||
export async function getTutorCapStatusSummary(
|
||||
orgId: string,
|
||||
period: 'current' | 'previous' | 'last90' = 'current'
|
||||
): Promise<{ atCap: number; approaching: number; totalActive: number; cap: number; periodStart: string }> {
|
||||
const { start } = resolveTutorPeriod(period);
|
||||
const summary = await getCapStatusSummary(orgId, start, STUDENT_TUTOR_CAP, STUDENT_TUTOR_APPROACHING_THRESHOLD);
|
||||
|
||||
return { ...summary, cap: STUDENT_TUTOR_CAP, periodStart: start.toISOString() };
|
||||
}
|
||||
|
||||
export interface AdminLearnerDetail {
|
||||
userId: string;
|
||||
cap: number;
|
||||
messageCount: number;
|
||||
capRemaining: number;
|
||||
status: AdminLeaderboardEntry['status'];
|
||||
dailyUsage: { date: string; messages: number; tokens: number }[];
|
||||
courseBreakdown: { courseId: string; courseTitle: string | null; tokens: number; messages: number }[];
|
||||
capEvents: { id: number; eventType: string; occurredAt: string; courseId: string | null }[];
|
||||
periodStart: string;
|
||||
}
|
||||
|
||||
export async function getTutorLearnerDetail(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
period: 'current' | 'previous' | 'last90' = 'current'
|
||||
): Promise<AdminLearnerDetail> {
|
||||
const { start } = resolveTutorPeriod(period);
|
||||
const monthly = await getMonthlyTutorCount(orgId, userId, start);
|
||||
const [dailyUsage, courseBreakdown, capEvents] = await Promise.all([
|
||||
getLearnerDailyUsage(orgId, userId, start),
|
||||
getLearnerCourseBreakdown(orgId, userId, start),
|
||||
getLearnerCapEvents(orgId, userId, start)
|
||||
]);
|
||||
|
||||
const capPct = monthly.messageCount / STUDENT_TUTOR_CAP;
|
||||
const status: AdminLeaderboardEntry['status'] =
|
||||
monthly.messageCount >= STUDENT_TUTOR_CAP
|
||||
? 'at_cap'
|
||||
: capPct >= STUDENT_TUTOR_APPROACHING_THRESHOLD
|
||||
? 'approaching'
|
||||
: 'under';
|
||||
|
||||
return {
|
||||
userId,
|
||||
cap: STUDENT_TUTOR_CAP,
|
||||
messageCount: monthly.messageCount,
|
||||
capRemaining: Math.max(0, STUDENT_TUTOR_CAP - monthly.messageCount),
|
||||
status,
|
||||
dailyUsage,
|
||||
courseBreakdown,
|
||||
capEvents,
|
||||
periodStart: start.toISOString()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user