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
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
+1
View File
@@ -0,0 +1 @@
plans/
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"svelte": {
"enabled": true
}
}
}
+8
View File
@@ -46,6 +46,14 @@ vite.config.ts*.mjs
tsconfig.tsbuildinfo*
# Stray TS build artifacts emitted next to sources (e.g. from `tsc <file>` without project)
apps/api/src/**/*.js
apps/api/src/**/*.js.map
apps/api/src/**/*.d.ts
!apps/api/src/types/css-tree.d.ts
storybook-static
.svelte-kit/
company/
+1
View File
@@ -0,0 +1 @@
reporter=append-only
+38
View File
@@ -2,6 +2,10 @@
This document collects implementation rules and workflow conventions for code changes.
## Research Requirements
When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway.
## Naming Convention
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
@@ -77,6 +81,40 @@ import { accountRouter } from '@api/routes/account';
export const app = new Hono().route('/account', accountRouter);
```
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
```typescript
// apps/api/src/routes/internal/index.ts
import { Hono } from '@api/utils/hono';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter);
// apps/api/src/app.ts
import { internalRouter } from '@api/routes/internal';
export const app = new Hono().route('/internal', internalRouter);
```
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
```typescript
// apps/api/src/routes/internal/index.ts
import { Hono } from '@api/utils/hono';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter);
// apps/api/src/app.ts
import { internalRouter } from '@api/routes/internal';
export const app = new Hono().route('/internal', internalRouter);
```
### Step 6: Build
```bash
pnpm --filter @cio/api build
+26
View File
@@ -9,12 +9,20 @@ BETTER_AUTH_SECRET=""
AUTH_BEARER_TOKEN=""
AUTH_COOKIE_DOMAIN=""
# Email provider — used by the API only for auth-flow emails (forgot
# password, password-reset confirmation, email verification, email-change
# verification). Every other email is sent by @cio/jobs-worker; see
# apps/jobs/.env.example. Both processes can point at the same SMTP server.
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_SENDER=""
SMTP_PASSWORD=""
# Optional Zoho ZeptoMail token. When set, @cio/email uses Zoho instead of
# nodemailer/SMTP. Same dual-process note as SMTP above.
# ZOHO_TOKEN=""
# Optional - MinIO (S3-compatible, for Docker / on-premise self-hosting)
# When API runs in Docker: use http://minio:9000. For local API: use http://localhost:9000
MINIO_ROOT_USER=""
@@ -34,6 +42,19 @@ CLOUDFLARE_BUCKET_DOMAIN=""
CLOUDFLARE_SECRET_ACCESS_KEY=""
SENTRY_DNS=""
# BullMQ dashboard (/admin/queues) ───────────────────────────────────────────
# In dev the dashboard is open. In production it's hidden unless ONE of the
# following is set; failing requests get 404 (not 401) so the route stays
# invisible to scanners.
#
# Comma-separated allowlist of Better Auth user emails — log in normally,
# visit /admin/queues, the middleware checks your session.
# QUEUE_DASHBOARD_ADMIN_EMAILS="you@yourdomain.com,partner@yourdomain.com"
#
# Bearer-token escape hatch for curl / scripts. Pass via
# `Authorization: Bearer <token>` or `?token=<token>`.
# QUEUE_DASHBOARD_TOKEN=""
# Enterprise (Optional)
# Required only when using Enterprise-only features (SSO, token-auth, no-tracking)
LICENSE_KEY=""
@@ -51,6 +72,11 @@ GOOGLE_API_KEY=""
ANTHROPIC_API_KEY=""
MOONSHOT_API_KEY=""
# Jina Reader — optional Bearer for higher rate limits when using fetch_documentation_url
# JINA_API_KEY=""
# AGENT_MAX_FETCHES_PER_CONVERSATION=15
# AGENT_MAX_FETCHES_PER_ORG_PER_DAY=500
# Tinybird event tracking for AI observability (optional — silently skipped when not set)
# TINYBIRD_TOKEN=""
# TINYBIRD_BASE_URL="https://api.tinybird.co"
+1
View File
@@ -1 +1,2 @@
static
dist
+7 -2
View File
@@ -12,7 +12,7 @@
"build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build",
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
"dev:server": "tsx watch src/index.ts",
"dev:tsc": "tsc -p tsconfig.json --watch",
"dev:tsc": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"deploy": "wrangler deploy",
"start": "node dist/index.js",
"test": "vitest NODE_ENV=test",
@@ -40,9 +40,14 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
"@bull-board/api": "^7.0.0",
"@bull-board/hono": "^7.0.0",
"@cio/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/certificates": "workspace:*",
"@cio/db": "workspace:*",
"@cio/email": "workspace:*",
"@cio/jobs": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
"@hono/node-server": "^1.19.7",
@@ -76,7 +81,7 @@
"@typescript-eslint/parser": "7.0.1",
"@vitest/coverage-v8": "^1.2.2",
"eslint": "^9.31.0",
"prettier": "^3.2.5",
"prettier": "3.6.2",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.1",
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env tsx
'use strict';
var __assign =
(this && this.__assign) ||
function () {
__assign =
Object.assign ||
function (t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator =
(this && this.__generator) ||
function (thisArg, body) {
var _ = {
label: 0,
sent: function () {
if (t[0] & 1) throw t[1];
return t[1];
},
trys: [],
ops: []
},
f,
y,
t,
g = Object.create((typeof Iterator === 'function' ? Iterator : Object).prototype);
return (
(g.next = verb(0)),
(g['throw'] = verb(1)),
(g['return'] = verb(2)),
typeof Symbol === 'function' &&
(g[Symbol.iterator] = function () {
return this;
}),
g
);
function verb(n) {
return function (v) {
return step([n, v]);
};
}
function step(op) {
if (f) throw new TypeError('Generator is already executing.');
while ((g && ((g = 0), op[0] && (_ = 0)), _))
try {
if (
((f = 1),
y &&
(t = op[0] & 2 ? y['return'] : op[0] ? y['throw'] || ((t = y['return']) && t.call(y), 0) : y.next) &&
!(t = t.call(y, op[1])).done)
)
return t;
if (((y = 0), t)) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0:
case 1:
t = op;
break;
case 4:
_.label++;
return { value: op[1], done: false };
case 5:
_.label++;
y = op[1];
op = [0];
continue;
case 7:
op = _.ops.pop();
_.trys.pop();
continue;
default:
if (!((t = _.trys), (t = t.length > 0 && t[t.length - 1])) && (op[0] === 6 || op[0] === 2)) {
_ = 0;
continue;
}
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) {
_.label = op[1];
break;
}
if (op[0] === 6 && _.label < t[1]) {
_.label = t[1];
t = op;
break;
}
if (t && _.label < t[2]) {
_.label = t[2];
_.ops.push(op);
break;
}
if (t[2]) _.ops.pop();
_.trys.pop();
continue;
}
op = body.call(thisArg, _);
} catch (e) {
op = [6, e];
y = 0;
} finally {
f = t = 0;
}
if (op[0] & 5) throw op[1];
return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, '__esModule', { value: true });
exports.OpenAPISpecGenerator = void 0;
require('dotenv/config');
var client_s3_1 = require('@aws-sdk/client-s3');
var fs_1 = require('fs');
var app_1 = require('../src/app');
var env_1 = require('../src/config/env');
var hono_openapi_1 = require('hono-openapi');
var path_1 = require('path');
function filterPublicApiSpec(spec) {
var _a, _b;
var paths = (_a = spec.paths) !== null && _a !== void 0 ? _a : {};
var publicApiPaths = Object.fromEntries(
Object.entries(paths).filter(function (_a) {
var path = _a[0];
return path.startsWith('/public-api/v1/');
})
);
var tags = Array.isArray(spec.tags)
? spec.tags.filter(function (tag) {
if (!tag || typeof tag !== 'object' || !('name' in tag)) {
return false;
}
return typeof tag.name === 'string' && tag.name.startsWith('Public API');
})
: undefined;
return __assign(__assign({}, spec), {
openapi: (_b = spec.openapi) !== null && _b !== void 0 ? _b : '3.1.0',
info: {
title: 'ClassroomIO Public API',
version: '1.0.0',
description: 'Public API for managing organizations, audience members, and courses in ClassroomIO.'
},
paths: publicApiPaths,
tags: tags
});
}
var OpenAPISpecGenerator = /** @class */ (function () {
function OpenAPISpecGenerator() {
this.s3Client = null;
this.initializeS3Client();
}
OpenAPISpecGenerator.prototype.initializeS3Client = function () {
if (env_1.env.CLOUDFLARE_ACCESS_KEY && env_1.env.CLOUDFLARE_SECRET_ACCESS_KEY && env_1.env.CLOUDFLARE_ACCOUNT_ID) {
this.s3Client = new client_s3_1.S3Client({
region: 'auto',
endpoint: 'https://'.concat(env_1.env.CLOUDFLARE_ACCOUNT_ID, '.r2.cloudflarestorage.com'),
credentials: {
accessKeyId: env_1.env.CLOUDFLARE_ACCESS_KEY,
secretAccessKey: env_1.env.CLOUDFLARE_SECRET_ACCESS_KEY
}
});
console.log('✅ S3 client initialized for Cloudflare R2');
} else {
console.warn('⚠️ Cloudflare R2 credentials not found. Will only generate local spec file.');
}
};
OpenAPISpecGenerator.prototype.generateSpec = function () {
return __awaiter(this, void 0, void 0, function () {
var spec, publicApiSpec, specString, error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
console.log('🔄 Generating OpenAPI specification...');
return [
4 /*yield*/,
(0, hono_openapi_1.generateSpecs)(app_1.app, {
documentation: {
info: {
title: 'ClassroomIO API',
version: '1.0.0',
description: 'Manage your organization on classroomio via the API',
contact: {
name: 'ClassroomIO',
url: 'https://classroomio.com'
}
},
servers: [
{
url: 'https://api.classroomio.com',
description: 'Production environment'
},
{
url: 'http://localhost:3002',
description: 'Development environment'
}
]
}
})
];
case 1:
spec = _a.sent();
publicApiSpec = filterPublicApiSpec(spec);
specString = JSON.stringify(publicApiSpec, null, 2);
console.log('✅ OpenAPI specification generated successfully');
return [2 /*return*/, specString];
case 2:
error_1 = _a.sent();
console.error('❌ Error generating OpenAPI spec:', error_1);
throw error_1;
case 3:
return [2 /*return*/];
}
});
});
};
OpenAPISpecGenerator.prototype.saveLocalSpec = function (spec) {
return __awaiter(this, void 0, void 0, function () {
var outputDir, filePath;
return __generator(this, function (_a) {
try {
outputDir = (0, path_1.join)(process.cwd(), 'dist', 'openapi', 'public-api');
(0, fs_1.mkdirSync)(outputDir, { recursive: true });
filePath = (0, path_1.join)(outputDir, 'openapi.json');
(0, fs_1.writeFileSync)(filePath, spec, 'utf8');
console.log('\u2705 OpenAPI spec saved locally: '.concat(filePath));
return [2 /*return*/, filePath];
} catch (error) {
console.error('❌ Error saving local spec:', error);
throw error;
}
return [2 /*return*/];
});
});
};
OpenAPISpecGenerator.prototype.uploadToR2 = function (options) {
return __awaiter(this, void 0, void 0, function () {
var command, error_2;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!this.s3Client) {
throw new Error('S3 client not initialized. Check your Cloudflare R2 credentials.');
}
_a.label = 1;
case 1:
_a.trys.push([1, 3, , 4]);
console.log('\uD83D\uDD04 Uploading to R2 bucket');
command = new client_s3_1.PutObjectCommand({
Bucket: 'api',
Key: options.key,
Body: options.content,
ContentType: options.contentType || 'application/json',
CacheControl: 'public, max-age=3600' // Cache for 1 hour
});
return [4 /*yield*/, this.s3Client.send(command)];
case 2:
_a.sent();
console.log('\u2705 Successfully uploaded to R2');
return [3 /*break*/, 4];
case 3:
error_2 = _a.sent();
console.error('❌ Error uploading to R2:', error_2);
throw error_2;
case 4:
return [2 /*return*/];
}
});
});
};
OpenAPISpecGenerator.prototype.generateAndUpload = function () {
return __awaiter(this, void 0, void 0, function () {
var spec, uploadKey, error_3;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 6, , 7]);
return [4 /*yield*/, this.generateSpec()];
case 1:
spec = _a.sent();
return [4 /*yield*/, this.saveLocalSpec(spec)];
case 2:
_a.sent();
if (!this.s3Client) return [3 /*break*/, 5];
uploadKey = 'openapi/public-api/openapi-'.concat(new Date().toISOString().split('T')[0], '.json');
return [
4 /*yield*/,
this.uploadToR2({
key: uploadKey,
content: spec,
contentType: 'application/json'
})
];
case 3:
_a.sent();
// Also upload as latest version
return [
4 /*yield*/,
this.uploadToR2({
key: 'openapi/public-api/openapi-latest.json',
content: spec,
contentType: 'application/json'
})
];
case 4:
// Also upload as latest version
_a.sent();
_a.label = 5;
case 5:
console.log('🎉 OpenAPI spec generation and upload completed successfully!');
return [3 /*break*/, 7];
case 6:
error_3 = _a.sent();
console.error('❌ Failed to generate and upload OpenAPI spec:', error_3);
process.exit(1);
return [3 /*break*/, 7];
case 7:
return [2 /*return*/];
}
});
});
};
return OpenAPISpecGenerator;
})();
exports.OpenAPISpecGenerator = OpenAPISpecGenerator;
// CLI interface
function main() {
return __awaiter(this, void 0, void 0, function () {
var args, generator;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(
'\nUsage: tsx scripts/upload-openapi-spec.ts\n\nExamples:\n tsx scripts/upload-openapi-spec.ts\n\nEnvironment Variables Required for R2 Upload:\n CLOUDFLARE_ACCESS_KEY\n CLOUDFLARE_SECRET_ACCESS_KEY\n CLOUDFLARE_ACCOUNT_ID\n '
);
return [2 /*return*/];
}
generator = new OpenAPISpecGenerator();
return [4 /*yield*/, generator.generateAndUpload()];
case 1:
_a.sent();
return [2 /*return*/];
}
});
});
}
// Run the script if called directly
if (process.argv[1] && process.argv[1].endsWith('upload-openapi-spec.ts')) {
main()
.then(function () {
console.log('Script completed successfully');
process.exit(0);
})
.catch(function (error) {
console.error('Script failed:', error);
process.exit(1);
});
}
+10 -3
View File
@@ -14,6 +14,7 @@ import { inviteRouter } from '@api/routes/invite';
import { logger } from 'hono/logger';
// ROUTES
import { mailRouter } from '@api/routes/mail';
import { jobsRouter } from '@api/routes/jobs';
import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
@@ -31,7 +32,8 @@ import { signupGuard } from '@api/middlewares/signup-guard';
import { programRouter } from '@api/routes/program';
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
import { agentRouter } from '@api/routes/agent';
import { internalComplianceRouter } from '@api/routes/internal/compliance';
import { internalRouter } from '@api/routes/internal';
import { mountQueueDashboard } from '@api/routes/admin/queues';
// Create Hono app with chaining for RPC support
export const app = new Hono()
@@ -43,7 +45,7 @@ export const app = new Hono()
'*',
cors({
origin: TRUSTED_ORIGINS,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600,
@@ -94,6 +96,7 @@ export const app = new Hono()
.route('/domain', domainRouter)
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/jobs', jobsRouter)
.route('/license', licenseRouter)
.route('/organization', organizationRouter)
.route('/organization/sso', organizationSsoRouter)
@@ -107,7 +110,7 @@ export const app = new Hono()
.route('/program', programRouter)
.route('/unsplash', unsplashRouter)
.route('/widgets', publicWidgetsRouter)
.route('/internal/compliance', internalComplianceRouter)
.route('/internal', internalRouter)
.route('/agent', agentRouter)
// Error handling
@@ -115,3 +118,7 @@ export const app = new Hono()
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
// Dev-only BullMQ dashboard at /admin/queues. Mounted after the typed RPC
// chain so it doesn't pollute the client type.
mountQueueDashboard(app);
+41 -1
View File
@@ -48,7 +48,47 @@ const envSchema = z.object({
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
LICENSE_KEY: z.string().optional(),
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
DASHBOARD_ORIGIN: z.string().optional()
DASHBOARD_ORIGIN: z.string().optional(),
/**
* Comma-separated list of Better Auth user emails allowed to access the
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the
* dashboard hidden everywhere except dev.
*/
QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(),
/**
* Bearer-token escape hatch for /admin/queues — useful for curl scripts or
* when SSO is unavailable. When set, requests with `Authorization: Bearer
* <token>` (or `?token=<token>`) bypass the email allowlist.
*/
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
/** Optional Bearer token for Jina Reader (higher rate limits). */
JINA_API_KEY: z.string().optional(),
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */
AGENT_MAX_FETCHES_PER_CONVERSATION: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 15;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 15;
}),
/** Max fetch_documentation_url calls per org per UTC day (default 500). */
AGENT_MAX_FETCHES_PER_ORG_PER_DAY: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 500;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500;
})
});
export const env = envSchema.parse(process.env);
+1 -1
View File
@@ -1,5 +1,5 @@
// API-specific constants (file size limits)
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_FILE_SIZE = 800 * 1024 * 1024; // 800MB
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
@@ -0,0 +1,48 @@
import { Context, Next } from 'hono';
import * as schema from '@db/schema';
import { ErrorCodes } from '@api/utils/errors';
import { db } from '@cio/db/drizzle';
import { eq } from 'drizzle-orm';
/**
* Blocks mutating requests on workspaces flagged read-only by the
* multi-workspace downgrade grace job. Read methods always pass through.
*/
export const workspaceNotReadOnlyMiddleware = async (c: Context, next: Next) => {
const method = c.req.method.toUpperCase();
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
await next();
return;
}
const orgId = c.req.header('cio-org-id');
if (!orgId) {
await next();
return;
}
try {
const [row] = await db
.select({ readOnlyUntil: schema.organization.readOnlyUntil })
.from(schema.organization)
.where(eq(schema.organization.id, orgId))
.limit(1);
if (row?.readOnlyUntil && new Date(row.readOnlyUntil) > new Date()) {
return c.json(
{
success: false,
error: 'Workspace is read-only while account is over plan limits',
code: ErrorCodes.WORKSPACE_READ_ONLY
},
403
);
}
} catch (error) {
console.error('workspaceNotReadOnlyMiddleware error:', error);
}
await next();
};
+2
View File
@@ -2,12 +2,14 @@ import { getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
import { accountWorkspacesRouter } from '@api/routes/account/workspaces';
import { authMiddleware } from '@api/middlewares/auth';
import { getProfileById } from '@cio/db/queries/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono()
.route('/', accountWorkspacesRouter)
.get('/', authMiddleware, async (c) => {
const user = c.get('user')!;
+67
View File
@@ -0,0 +1,67 @@
import {
createWorkspaceForAccount,
deleteWorkspaceFromAccount,
getAccountUsage,
getWorkspaceLimits,
listAccountWorkspaces
} from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZCreateWorkspace, ZWorkspaceIdParam } from '@cio/utils/validation/account';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { zValidator } from '@hono/zod-validator';
export const accountWorkspacesRouter = new Hono()
.get('/workspaces', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const [workspaces, limits] = await Promise.all([listAccountWorkspaces(orgId), getWorkspaceLimits(orgId)]);
return c.json({ success: true, data: { workspaces, limits } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch account workspaces');
}
})
.post('/workspaces', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateWorkspace), async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const data = c.req.valid('json');
const workspace = await createWorkspaceForAccount(orgId, user.id, data);
return c.json({ success: true, data: workspace }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create workspace');
}
})
.delete(
'/workspaces/:workspaceId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZWorkspaceIdParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { workspaceId } = c.req.valid('param');
await deleteWorkspaceFromAccount(orgId, workspaceId);
return c.json({ success: true, data: { id: workspaceId } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete workspace');
}
}
)
.get('/usage', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const usage = await getAccountUsage(orgId);
return c.json({ success: true, data: usage }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch account usage');
}
});
+105
View File
@@ -0,0 +1,105 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import type { Context, Hono, Next } from 'hono';
import { QUEUE_NAMES, getQueue, isRedisConfigured } from '@cio/jobs';
import { env } from '@api/config/env';
/**
* Mounts the bull-board UI at `/admin/queues` for inspecting BullMQ state
* (waiting/active/completed/failed jobs, payloads, retries, etc.).
*
* Access control:
* - Development: open (matches existing dev ergonomics).
* - Production: requires either
* - a Better Auth session whose `user.email` is in
* `QUEUE_DASHBOARD_ADMIN_EMAILS` (comma-separated), or
* - the `QUEUE_DASHBOARD_TOKEN` bearer escape hatch via header or `?token=`.
* Failures return `404` so the route doesn't reveal its existence.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mountQueueDashboard(app: Hono<any, any, any>): void {
if (!isRedisConfigured()) {
console.warn('Queue dashboard not mounted: REDIS_URL is not set.');
return;
}
const isProduction = env.NODE_ENV === 'production';
if (isProduction && !canEnforceProdAuth()) {
console.warn(
'Queue dashboard not mounted: NODE_ENV=production requires QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN.'
);
return;
}
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: Object.values(QUEUE_NAMES).map((name) => new BullMQAdapter(getQueue(name))),
serverAdapter
});
if (isProduction) {
app.use('/admin/queues/*', queueDashboardAuth);
app.use('/admin/queues', queueDashboardAuth);
}
app.route('/admin/queues', serverAdapter.registerPlugin());
}
function canEnforceProdAuth(): boolean {
return Boolean(adminEmailSet().size > 0 || env.QUEUE_DASHBOARD_TOKEN);
}
function adminEmailSet(): Set<string> {
const raw = env.QUEUE_DASHBOARD_ADMIN_EMAILS ?? '';
return new Set(
raw
.split(',')
.map((email) => email.trim().toLowerCase())
.filter(Boolean)
);
}
async function queueDashboardAuth(c: Context, next: Next): Promise<Response | void> {
if (matchesEscapeHatch(c)) {
return next();
}
const user = c.get('user') as { email?: string | null } | null;
const email = user?.email?.toLowerCase();
if (email && adminEmailSet().has(email)) {
return next();
}
// Hide existence rather than 401/403 — fewer hints for crawlers/scanners.
return c.notFound();
}
function matchesEscapeHatch(c: Context): boolean {
const expected = env.QUEUE_DASHBOARD_TOKEN;
if (!expected) return false;
const headerToken = c.req
.header('Authorization')
?.replace(/^Bearer\s+/i, '')
.trim();
const queryToken = c.req.query('token');
const provided = headerToken || queryToken;
return Boolean(provided && timingSafeEqual(provided, expected));
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i += 1) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}
+262 -30
View File
@@ -11,7 +11,12 @@ import {
ZAgentCreditPurchase,
ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery
ZAgentGenerateTextBody,
ZAgentStatusQuery,
ZAgentSummarizeBody,
ZTutorUsageQuery,
ZTutorUsagePeriod,
ZTutorUsageUserParam
} from '@cio/utils/validation/agent';
import {
addCredits,
@@ -23,15 +28,32 @@ import {
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
import {
enforceStudentTutorPolicy,
getStudentTutorStatus,
getTutorCapStatusSummary,
getTutorLearnerDetail,
getTutorLearnerLeaderboard,
incrementStudentTutorCount
} from '@api/services/agent/tutor-usage';
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-generation';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent';
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant';
import {
AgentRole,
AIProvider,
MAX_STEPS_PER_ROUND,
getCourseTemplate,
type AgentContext,
type AgentStatus
} from '@cio/ai-assistant';
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
import { buildSystemPrompt } from '@cio/ai-assistant/prompt';
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db';
@@ -41,14 +63,17 @@ import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { trimMessageHistory } from '@api/services/agent/context-window';
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import {
collectDocumentIds,
getActiveCourseTemplateId,
getLatestImplementationPlan,
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { summarizeConversation } from '@api/services/agent/summarize';
import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono()
@@ -63,7 +88,8 @@ const agentCoreRouter = new Hono()
const status: AgentStatus = {
enabled: false,
role: AgentRole.STUDENT,
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 },
tutor: { enabled: false, capRemaining: null, cap: null, enforced: false }
};
return c.json({ success: true, data: status });
@@ -74,10 +100,16 @@ const agentCoreRouter = new Hono()
const usage = await getTokenBalance(orgId);
const tutor =
role === AgentRole.STUDENT
? await getStudentTutorStatus(orgId, courseId, user.id)
: { enabled: true, cap: null, capRemaining: null, enforced: false };
const status: AgentStatus = {
enabled: true,
role,
usage
usage,
tutor
};
return c.json({ success: true, data: status });
@@ -187,6 +219,67 @@ const agentCoreRouter = new Hono()
return handleError(c, error, 'Failed to fetch usage stats');
}
})
.get(
'/tutor-usage/leaderboard',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZTutorUsageQuery),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { period, search, sort, page, limit } = c.req.valid('query');
const data = await getTutorLearnerLeaderboard(orgId, { period, search, sort, page, limit });
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner leaderboard');
}
}
)
.get(
'/tutor-usage/summary',
authMiddleware,
orgMemberMiddleware,
zValidator(
'query',
ZTutorUsagePeriod.optional()
.transform((v) => v ?? 'current')
.pipe(ZTutorUsagePeriod)
.or(ZTutorUsagePeriod)
.transform((v) => ({ period: v }))
),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorCapStatusSummary(orgId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch tutor cap summary');
}
}
)
.get(
'/tutor-usage/:userId',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZTutorUsageUserParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { userId } = c.req.valid('param');
const period = c.req.query('period');
const parsed = ZTutorUsagePeriod.safeParse(period);
const data = await getTutorLearnerDetail(orgId, userId, parsed.success ? parsed.data : 'current');
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to fetch learner detail');
}
}
)
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
@@ -233,6 +326,59 @@ const agentCoreRouter = new Hono()
}
}
)
.post(
'/generate-text',
authMiddleware,
orgMemberMiddleware,
zValidator('json', ZAgentGenerateTextBody),
async (c) => {
try {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
const { prompt, tone, format, context, courseId } = c.req.valid('json');
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
await enforceTokenBalance(orgId);
const { text, usage, modelName } = await generateFieldText(prompt, tone, format, context, providerConfig);
if (courseId) {
await recordTokenUsage(orgId, user.id, courseId, usage, modelName);
}
return c.json({ success: true as const, data: { text } });
} catch (error) {
return handleError(c, error, 'Failed to generate text');
}
}
)
.post('/summarize', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentSummarizeBody), async (c) => {
try {
const { messages, courseId } = c.req.valid('json');
const user = c.get('user')!;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
if (!isTeamMember) {
throw new AppError(
'You must be a course team member to summarize conversations',
'NOT_COURSE_TEAM_MEMBER',
403
);
}
const summary = await summarizeConversation({ messages });
return c.json({ success: true as const, data: { summary } });
} catch (error) {
return handleError(c, error, 'Failed to summarize conversation');
}
})
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!;
@@ -240,7 +386,14 @@ const agentCoreRouter = new Hono()
try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
// Students always use the platform default; admins can pick. Forcing the
// default avoids exposing premium models to learners and keeps per-message
// cost predictable for fair-use accounting.
const modelId =
role === AgentRole.STUDENT ? DEFAULT_PICKER_MODEL_ID : (requestedModel ?? DEFAULT_PICKER_MODEL_ID);
const modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
@@ -250,13 +403,6 @@ const agentCoreRouter = new Hono()
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
if (role === AgentRole.STUDENT) {
throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403);
}
const [courseRow] = await db
.select({
title: schema.course.title,
@@ -276,7 +422,7 @@ const agentCoreRouter = new Hono()
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
}
if (!modelDescriptor.isFree) {
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) {
@@ -284,7 +430,14 @@ const agentCoreRouter = new Hono()
}
}
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
// Teachers continue to use the existing pool-only check.
const studentPolicy =
role === AgentRole.STUDENT ? await enforceStudentTutorPolicy(orgId, courseId, user.id) : null;
if (role === AgentRole.TEACHER) {
await enforceTokenBalance(orgId);
}
const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
@@ -348,38 +501,98 @@ const agentCoreRouter = new Hono()
const startTime = Date.now();
const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages);
const systemPrompt = [
buildSystemPrompt(agentContext),
const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined;
const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined;
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
// Stable across requests — safe to cache as a long-lived Anthropic prefix.
// Volatile per-request context (lesson/exercise/document/section count/
// approved plan/active template) is sent as a user-turn message instead so
// it doesn't invalidate the tools+system cache.
const systemPrompt = buildSystemPrompt(agentContext, {
tutorSettings: studentPolicy?.settings
});
const contextMessageText = buildContextMessage(agentContext, {
template: activeTemplate,
approvedPlan
? [
'',
'The latest user message approved a final course plan for immediate execution.',
'Implement that exact plan directly without asking the user to restate it.',
'Treat this approved plan as the canonical source if it differs from any earlier draft.',
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}`
].join('\n')
: ''
].join('\n');
const agentTools = buildAgentTools(orgId, user.id, courseId);
});
const agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages);
const trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages);
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
let completedStepCount = 0;
let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
// 1h TTL keeps the prefix warm across tool-execution gaps in long agent
// runs. Break-even is 3 requests within the hour; well under most plan-
// execution loops.
const systemContent = isAnthropic
? {
role: 'system' as const,
content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } }
}
}
: systemPrompt;
// Prepend volatile context as a user-turn message so the stable system +
// tools prefix stays cacheable even when the teacher navigates to a
// different lesson or the agent creates sections mid-run.
const modelMessages =
contextMessageText.length > 0
? [
{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] },
...convertedMessages
]
: convertedMessages;
console.log(
'[agent.chat] modelMessages →',
JSON.stringify(
modelMessages.map((m, i) => ({
i,
role: m.role,
parts: Array.isArray(m.content)
? m.content.map((p: any) => ({
type: p.type,
toolCallId: p.toolCallId,
toolName: p.toolName,
textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined
}))
: typeof m.content === 'string'
? m.content.slice(0, 80)
: m.content
})),
null,
2
)
);
// Cache the growing conversation prefix: each turn reads the prior
// transcript at ~0.1x cost instead of reprocessing it at full price.
if (isAnthropic && modelMessages.length > 0) {
const lastMessage = modelMessages[modelMessages.length - 1];
const existingAnthropic = (lastMessage.providerOptions?.anthropic as Record<string, unknown> | undefined) ?? {};
lastMessage.providerOptions = {
...(lastMessage.providerOptions ?? {}),
anthropic: {
...existingAnthropic,
cacheControl: { type: 'ephemeral', ttl: '1h' }
}
};
}
const result = streamText({
model,
maxRetries: 0,
maxRetries: 2,
system: systemContent,
messages: modelMessages,
tools: agentTools,
@@ -394,6 +607,21 @@ const agentCoreRouter = new Hono()
const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 0;
// Cache hit/miss visibility. If cacheRead stays 0 across repeated
// turns of the same conversation, a silent invalidator is leaking
// into the cached prefix — audit system prompt and tool definitions.
if (isAnthropic) {
const details = totalUsage?.inputTokenDetails;
const cacheRead = details?.cacheReadTokens ?? 0;
const cacheWrite = details?.cacheWriteTokens ?? 0;
const uncached = details?.noCacheTokens ?? inputTokens;
const total = uncached + cacheRead + cacheWrite;
const hitRate = total > 0 ? Math.round((cacheRead / total) * 100) : 0;
console.log(
`[agent.chat] cache hit=${hitRate}% read=${cacheRead} write=${cacheWrite} uncached=${uncached} output=${outputTokens}`
);
}
if (totalUsage) {
await recordTokenUsage(
orgId,
@@ -408,6 +636,10 @@ const agentCoreRouter = new Hono()
);
}
if (role === AgentRole.STUDENT) {
await incrementStudentTutorCount(orgId, user.id, courseId);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId,
userId: user.id,
+55
View File
@@ -7,6 +7,7 @@ import {
ZAgentConversationParam,
ZAgentConversationCreateBody,
ZAgentHistorySaveBody,
ZAgentHistoryRenameBody,
ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody
@@ -20,6 +21,7 @@ import {
deleteChatConversation,
updateConversationTitle
} from '@api/services/agent/chat-history';
import { compactConversation } from '@api/services/agent/compact';
import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
@@ -110,6 +112,37 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* PATCH /agent/history/:conversationId
* Rename a conversation without re-uploading messages.
*/
.patch(
'/:conversationId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
zValidator('json', ZAgentHistoryRenameBody),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const { title } = c.req.valid('json');
const conversation = await getChatConversation(conversationId, user.id);
if (!conversation) {
return c.json({ success: false as const, error: 'Conversation not found' }, 404);
}
await updateConversationTitle(conversationId, user.id, title);
return c.json({ success: true as const, data: { id: conversationId, title } });
} catch (error) {
return handleError(c, error, 'Failed to rename conversation');
}
}
)
/**
* DELETE /agent/history/:conversationId
* Delete a conversation.
@@ -133,6 +166,28 @@ export const agentHistoryRouter = new Hono()
}
)
/**
* POST /agent/history/:conversationId/compact
* Summarize and replace persisted messages with a single seed message.
*/
.post(
'/:conversationId/compact',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAgentConversationParam),
async (c) => {
try {
const user = c.get('user')!;
const { conversationId } = c.req.valid('param');
const data = await compactConversation(conversationId, user.id);
return c.json({ success: true as const, data });
} catch (error) {
return handleError(c, error, 'Failed to compact conversation');
}
}
)
/**
* POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model.
+39
View File
@@ -0,0 +1,39 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
import { getCourseAiTutorSettingsView, updateCourseAiTutorOverrideService } from '@api/services/agent/tutor-config';
/**
* Per-course AI tutor override.
*
* GET /course/:courseId/ai-tutor → { org, override, effective }
* PUT /course/:courseId/ai-tutor → updates the per-course override slot.
* Mounted at /course/:courseId/ai-tutor.
*/
export const courseAiTutorRouter = new Hono()
.get('/', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const courseId = c.req.param('courseId') as string;
const view = await getCourseAiTutorSettingsView(orgId, courseId);
return c.json({ success: true as const, data: view });
} catch (error) {
return handleError(c, error, 'Failed to fetch course AI tutor settings');
}
})
.put('/', authMiddleware, courseTeamMemberMiddleware, zValidator('json', ZCourseAiTutorOverride), async (c) => {
try {
const courseId = c.req.param('courseId') as string;
const patch = c.req.valid('json');
const updated = await updateCourseAiTutorOverrideService(courseId, patch);
return c.json({ success: true as const, data: updated });
} catch (error) {
return handleError(c, error, 'Failed to update course AI tutor override');
}
});
+84 -10
View File
@@ -1,5 +1,5 @@
import {
ZCertificateDownload,
ZCertificateDownloadRequest,
ZCourseClone,
ZCourseCloneParam,
ZCourseCreate,
@@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance';
import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone';
@@ -43,9 +44,11 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
import { enrollInCourse } from '@api/services/course/invite';
import { exerciseRouter } from '@api/routes/course/exercise';
import { extractClientIp } from '@api/utils/redis/key-generators';
import { generateCertificate } from '@api/utils/certificate';
import { generateCertificatePdf, generateCertificatePng } from '@api/utils/certificate';
import { assembleCertificateRender, assembleOwnerPreviewRender } from '@api/services/course/certificate';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { generateCoursePdf } from '@api/utils/course';
import { handleError } from '@api/utils/errors';
import { AppError, ErrorCodes, handleError } from '@api/utils/errors';
import { invitesRouter } from '@api/routes/course/invite';
import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson';
@@ -62,6 +65,36 @@ import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
function slugifyForFilename(value: string): string {
return (
value
.normalize('NFKD')
.replace(/[^a-zA-Z0-9 ]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'certificate'
);
}
async function loadCertificateInput(
courseId: string,
userId: string,
body: import('@cio/utils/validation/course').TCertificateDownloadRequest
) {
if (body.previewMode) {
const isTeam = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
if (!isTeam) {
throw new AppError('Only course team members can preview certificate designs', ErrorCodes.UNAUTHORIZED, 403);
}
return assembleOwnerPreviewRender(courseId, userId, body);
}
await assertCertificateDownloadAllowed(courseId, userId);
return assembleCertificateRender(courseId, body);
}
const enrollRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
@@ -454,27 +487,67 @@ export const courseRouter = new Hono()
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id);
const body = c.req.valid('json');
const validatedData = c.req.valid('json');
const result = await generateCertificate(validatedData);
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePdf(input);
c.header('Content-Type', 'application/pdf');
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.pdf"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(result);
controller.enqueue(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate');
}
}
)
.post(
'/:courseId/download/certificate/png',
authMiddleware,
courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownloadRequest),
async (c) => {
try {
const { courseId } = c.req.valid('param');
const user = c.get('user')!;
const body = c.req.valid('json');
const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePng(input);
c.header('Content-Type', 'image/png');
c.header(
'Content-Disposition',
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.png"`
);
return c.body(
new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
}
})
);
} catch (error) {
return handleError(c, error, 'Failed to download certificate image');
}
}
)
.post(
@@ -542,4 +615,5 @@ export const courseRouter = new Hono()
.route('/:courseId/newsfeed', newsfeedRouter)
.route('/:courseId/members', membersRouter)
.route('/:courseId/invites', invitesRouter)
.route('/:courseId/ai-tutor', courseAiTutorRouter)
.route('/presign', presignRouter);
+99 -2
View File
@@ -1,13 +1,44 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash';
import {
ZDashAnalyticsRange,
ZDashComplianceOverview,
ZDashCourseFunnel,
ZDashStats,
ZIngestBatch,
ZLoginActivity
} from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
import { getOrgComplianceOverview } from '@api/services/course/compliance';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import {
getCountryBreakdown,
getCourseFunnel,
getLandingStats,
getPopularTypes,
ingestEventBatch
} from '@api/services/analytics';
export const dashAnalyticsRouter = new Hono()
.post('/track', zValidator('json', ZIngestBatch), async (c) => {
try {
const batch = c.req.valid('json');
const user = c.get('user');
const result = await ingestEventBatch(batch, {
country: c.req.header('cf-ipcountry') ?? c.req.header('CF-IPCountry') ?? null,
userAgent: c.req.header('user-agent') ?? null,
userId: user?.id ?? null
});
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to ingest analytics events');
}
})
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
try {
const { orgId, siteName } = c.req.valid('query');
@@ -39,4 +70,70 @@ export const dashAnalyticsRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to load login streak');
}
});
})
.get('/landing-stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getLandingStats(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load landing stats');
}
})
.get(
'/country-breakdown',
authMiddleware,
orgMemberMiddleware,
zValidator('query', ZDashAnalyticsRange),
async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getCountryBreakdown(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load country breakdown');
}
}
)
.get('/course-funnel', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashCourseFunnel), async (c) => {
try {
const { orgId, days, courseId } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getCourseFunnel(orgId, days, courseId, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load course funnel');
}
})
.get('/popular-types', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
try {
const { orgId, days } = c.req.valid('query');
const bust = c.req.query('bust') === '1';
const result = await getPopularTypes(orgId, days, bust);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load popular course types');
}
})
.get(
'/compliance-overview',
authMiddleware,
orgAdminMiddleware,
zValidator('query', ZDashComplianceOverview),
async (c) => {
try {
const { orgId } = c.req.valid('query');
const result = await getOrgComplianceOverview(orgId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load compliance overview');
}
}
);
+13
View File
@@ -0,0 +1,13 @@
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
import { runWorkspaceGraceCheck } from '@api/services/account';
export const internalAccountRouter = new Hono().post('/workspace-grace', apiKeyMiddleware, async (c) => {
try {
const data = await runWorkspaceGraceCheck();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run workspace grace check');
}
});
+18
View File
@@ -0,0 +1,18 @@
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { handleError } from '@api/utils/errors';
import { runAnalyticsRollupDaily } from '@cio/analytics';
export const internalAnalyticsRouter = new Hono().post('/rollup-daily', apiKeyMiddleware, async (c) => {
try {
const daysAgoHeader = c.req.query('daysAgo');
const daysAgo = daysAgoHeader ? Number(daysAgoHeader) : 1;
const result = await runAnalyticsRollupDaily({
daysAgo: Number.isFinite(daysAgo) && daysAgo > 0 ? daysAgo : 1
});
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run analytics rollup');
}
});
@@ -1,4 +1,5 @@
import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -34,4 +35,32 @@ export const internalComplianceRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to run compliance reminder scan');
}
})
/**
* POST /internal/compliance/evaluate-program-goals
* Daily sweep: re-evaluate every active program goal and refresh per-learner
* `program_goal_assignment` rows.
*/
.post('/evaluate-program-goals', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalEvaluationSweep();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal evaluation sweep');
}
})
/**
* POST /internal/compliance/send-program-goal-reminders
* Sends learner reminder emails for upcoming/overdue program goals using
* each goal's `reminderDaysBefore` cadence.
*/
.post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => {
try {
const data = await runProgramGoalReminderScan();
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to run program goal reminder scan');
}
});
+9
View File
@@ -0,0 +1,9 @@
import { Hono } from '@api/utils/hono';
import { internalAccountRouter } from './account';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter)
.route('/account', internalAccountRouter);
+1
View File
@@ -0,0 +1 @@
export * from './jobs';
+142
View File
@@ -0,0 +1,142 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import { ZAssetJobsParam, ZJobBatchQuery, ZJobIdParam } from '@cio/utils/validation/jobs';
import {
cancelMediaJob,
getMediaJobEnvelope,
getMediaJobSteps,
listMediaJobEnvelopes,
listMediaJobsForAssetEnvelopes,
startTranscriptionOnlyMediaJob
} from '@api/services/jobs';
import type { JobEnvelope } from '@cio/jobs/status';
/**
* Generic + media-flavoured job status routes.
*
* GET /jobs/media/:jobId -> single envelope (ETag/304 supported)
* GET /jobs/media?ids=a,b,c -> map keyed by job id
* GET /jobs/media/asset/:assetId -> all runs for an asset (lesson editor badges)
* POST /jobs/media/:jobId/cancel -> request cancellation
* GET /jobs/media/:jobId/steps -> ledger entries (admin / debug)
*
* POST /jobs/media/asset/:assetId/transcribe -> enqueue extract + whisper only (202)
*
* Domain-specific surfaces (e.g. /media/jobs) can re-export the same handlers
* once additional domains adopt the envelope. Returning a single shape per
* route keeps the RPC types unambiguous.
*/
function envelopeETag(envelope: JobEnvelope): string {
return `W/"${envelope.job.id}-${envelope.job.updatedAt}"`;
}
export const jobsRouter = new Hono()
.get(
'/media/:jobId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.extend({ jobId: ZJobIdParam.shape.jobId }).pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const envelope = await getMediaJobEnvelope(jobId, orgId);
const etag = envelopeETag(envelope);
const ifNoneMatch = c.req.header('if-none-match');
if (ifNoneMatch === etag) {
c.header('ETag', etag);
return c.body(null, 304);
}
c.header('ETag', etag);
c.header('Last-Modified', new Date(envelope.job.updatedAt).toUTCString());
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media job');
}
}
)
.get('/media', authMiddleware, orgMemberMiddleware, zValidator('query', ZJobBatchQuery), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { ids } = c.req.valid('query');
const envelopes = await listMediaJobEnvelopes(ids, orgId);
return c.json({ success: true, data: envelopes }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media jobs');
}
})
.get(
'/media/asset/:assetId',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetJobsParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const envelopes = await listMediaJobsForAssetEnvelopes(assetId, orgId);
return c.json({ success: true, data: envelopes }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media jobs for asset');
}
}
)
.post(
'/media/asset/:assetId/transcribe',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetJobsParam),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { assetId } = c.req.valid('param');
const job = await startTranscriptionOnlyMediaJob({
organizationId: orgId,
assetId,
triggeredByProfileId: user?.id ?? null
});
return c.json({ success: true, data: job }, 202);
} catch (error) {
return handleError(c, error, 'Failed to start transcription');
}
}
)
.post(
'/media/:jobId/cancel',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const envelope = await cancelMediaJob(jobId, orgId);
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to cancel media job');
}
}
)
.get(
'/media/:jobId/steps',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZJobIdParam.pick({ jobId: true })),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { jobId } = c.req.valid('param');
const steps = await getMediaJobSteps(jobId, orgId);
return c.json({ success: true, data: steps }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch media job steps');
}
}
);
+28 -14
View File
@@ -1,31 +1,45 @@
import { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { deliverEmail } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
/**
* Public mail endpoint — accepts a list of raw subject/content emails and
* enqueues them on the BullMQ `emails` queue via the transactional outbox.
* Returns 202 with the outbox + delivery ids so callers can poll status if
* needed.
*/
export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json');
try {
const results = await deliverEmail(validatedData);
const enqueued = await Promise.all(
validatedData.map((emailItem) =>
enqueueRawEmail({
from: emailItem.from,
to: emailItem.to,
subject: emailItem.subject,
content: emailItem.content,
replyTo: emailItem.replyTo
})
)
);
const jobIds = enqueued.flatMap((entry) => entry.jobIds);
const hasErrors = results.some((result) => !result.success);
if (hasErrors) {
return c.json(
{
success: false,
error: 'Some emails failed to send',
code: 'EMAIL_SEND_PARTIAL_FAILURE',
details: results
},
500
);
success: true,
data: {
accepted: jobIds.length,
jobIds
}
return c.json({ success: true, data: results }, 200);
},
202
);
} catch (error) {
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
return handleError(c, error, 'Failed to enqueue email send request', 'EMAIL_SEND_FAILED');
}
});
@@ -0,0 +1,38 @@
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
import { ZAiTutorSettingsUpdate } from '@cio/utils/validation/agent';
import { getResolvedOrgAiTutorSettings, updateOrgAiTutorSettingsService } from '@api/services/agent/tutor-config';
/**
* Org-level AI tutor configuration.
*
* GET /organization/ai-tutor — any org member can read the resolved settings.
* PUT /organization/ai-tutor — org admin only; merges the patch into stored settings.
*/
export const organizationAiTutorRouter = new Hono()
.get('/', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const settings = await getResolvedOrgAiTutorSettings(orgId);
return c.json({ success: true as const, data: settings });
} catch (error) {
return handleError(c, error, 'Failed to fetch AI tutor settings');
}
})
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZAiTutorSettingsUpdate), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const patch = c.req.valid('json');
const updated = await updateOrgAiTutorSettingsService(orgId, patch);
return c.json({ success: true as const, data: updated });
} catch (error) {
return handleError(c, error, 'Failed to update AI tutor settings');
}
});
@@ -19,6 +19,7 @@ import {
getAssetService,
getAssetUsageGraphService,
getOrganizationAssetStorageService,
getTranscriptForOrganizationAssetService,
getYouTubeMetadataService,
listOrganizationAssetsService,
updateAssetService
@@ -150,6 +151,27 @@ export const assetsRouter = new Hono()
}
}
)
/**
* GET /organization/assets/:assetId/transcript
* Whisper transcript + presigned VTT URL for captions (or null if none).
*/
.get('/:assetId/transcript', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = await getTranscriptForOrganizationAssetService(orgId, assetId);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to fetch transcript');
}
})
/**
* GET /organization/assets/:assetId
* Get a single asset by id
@@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono';
import { ROLE } from '@cio/utils/constants';
import { TOrganization } from '@db/types';
import { assetsRouter } from '@api/routes/organization/assets';
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
@@ -670,4 +671,5 @@ export const organizationRouter = new Hono()
.route('/tags', tagsRouter)
.route('/widgets', widgetsRouter)
.route('/assets', assetsRouter)
.route('/ai-tutor', organizationAiTutorRouter)
.route('/:orgId/quiz', quizRouter);
+1 -27
View File
@@ -3,19 +3,12 @@ import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import {
ZCreateWidget,
ZPreviewWidget,
ZRollbackWidget,
ZUpdateWidget,
ZWidgetIdParams
} from '@cio/utils/validation/widget';
import { ZCreateWidget, ZRollbackWidget, ZUpdateWidget, ZWidgetIdParams } from '@cio/utils/validation/widget';
import {
createOrganizationWidget,
deleteOrganizationWidget,
getOrganizationWidgetDetail,
listOrganizationWidgets,
previewOrganizationWidget,
publishOrganizationWidget,
rollbackOrganizationWidget,
updateOrganizationWidget
@@ -87,25 +80,6 @@ export const widgetsRouter = new Hono()
return handleError(c, error, 'Failed to delete widget');
}
})
.post(
'/:widgetId/preview',
authMiddleware,
orgTeamMemberMiddleware,
zValidator('param', ZWidgetIdParams),
zValidator('json', ZPreviewWidget),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { widgetId } = c.req.valid('param');
const data = c.req.valid('json');
const payload = await previewOrganizationWidget(orgId, widgetId, data);
return c.json({ success: true, data: payload });
} catch (error) {
return handleError(c, error, 'Failed to build widget preview');
}
}
)
.post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
+255
View File
@@ -3,10 +3,14 @@ import * as z from 'zod';
import {
ZAddCourseToProgram,
ZAddProgramMembers,
ZAssignExistingStudentsToProgram,
ZCreateProgram,
ZCreateProgramGoal,
ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment,
ZInviteStudentsToProgram,
ZUpdateProgram,
ZUpdateProgramGoal,
ZUpdateProgramMember,
ZUpdateProgramNewsfeed,
ZUpdateProgramReaction
@@ -34,9 +38,23 @@ import {
updateProgramNewsfeedReactionService,
updateProgramNewsfeedService
} from '@api/services/program/program';
import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite';
import {
archiveGoal,
createGoal,
evaluateGoal,
evaluateProgramGoals,
getGoal,
getMyGoals,
getOrgGoalsOverview,
listGoals,
removeGoal,
updateGoal
} from '@api/services/program/goal';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { programMemberMiddleware } from '@api/middlewares/program-member';
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
@@ -52,6 +70,7 @@ const ZCommentParam = z.object({
feedId: z.string().uuid(),
commentId: z.coerce.number().int()
});
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
const ZListQuery = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10)
@@ -247,6 +266,56 @@ export const programRouter = new Hono()
}
)
// ── Invite ────────────────────────────────────────────────────────────────
/**
* POST /program/:programId/invite
* Invite new students to a program by CSV emails. Issues org invites
* pre-tagged with this program so accept auto-enrolls them.
* Gated by program-team-member middleware — Program ADMINs/TUTORs can
* invite into their own program without org-admin rights.
*/
.post(
'/:programId/invite',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZInviteStudentsToProgram),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await inviteStudentsToProgram(programId, data, user.id);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to invite students to program');
}
}
)
/**
* POST /program/:programId/invite/assign
* Assign existing org-audience student profiles to a program.
*/
.post(
'/:programId/invite/assign',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZAssignExistingStudentsToProgram),
async (c) => {
try {
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await assignExistingStudentsToProgram(programId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to assign students to program');
}
}
)
// ── Courses ───────────────────────────────────────────────────────────────
/**
@@ -484,4 +553,190 @@ export const programRouter = new Hono()
return handleError(c, error, 'Failed to delete program newsfeed comment');
}
}
)
// ── Goals ─────────────────────────────────────────────────────────────────
/**
* GET /program/my/goals
* Goal assignments for the current authenticated user across all their programs.
* Used by the LMS "Your Goals" widget.
*/
.get('/my/goals', authMiddleware, async (c) => {
try {
const user = c.get('user')!;
const goals = await getMyGoals(user.id);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals');
}
})
/**
* GET /program/goals/overview?organizationId=...
* Cross-program goal roll-up for the org owner. One entry per active goal
* with per-status learner counts, scoped to the org via header + query.
*/
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
try {
const { organizationId } = c.req.valid('query');
const overview = await getOrgGoalsOverview(organizationId);
return c.json({ success: true, data: overview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load goals overview');
}
})
/**
* GET /program/:programId/goals
*/
.get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
try {
const { programId } = c.req.valid('param');
const goals = await listGoals(programId);
return c.json({ success: true, data: goals }, 200);
} catch (error) {
return handleError(c, error, 'Failed to list program goals');
}
})
/**
* POST /program/:programId/goals
*/
.post(
'/:programId/goals',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
zValidator('json', ZCreateProgramGoal),
async (c) => {
try {
const user = c.get('user')!;
const { programId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await createGoal(programId, user.id, data);
return c.json({ success: true, data: goal }, 201);
} catch (error) {
return handleError(c, error, 'Failed to create program goal');
}
}
)
/**
* GET /program/:programId/goals/:goalId
*/
.get(
'/:programId/goals/:goalId',
authMiddleware,
programMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await getGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to get program goal');
}
}
)
/**
* PUT /program/:programId/goals/:goalId
*/
.put(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
zValidator('json', ZUpdateProgramGoal),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const data = c.req.valid('json');
const goal = await updateGoal(goalId, data);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update program goal');
}
}
)
/**
* DELETE /program/:programId/goals/:goalId
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
*/
.delete(
'/:programId/goals/:goalId',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await removeGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to delete program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/archive
*/
.post(
'/:programId/goals/:goalId/archive',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const goal = await archiveGoal(goalId);
return c.json({ success: true, data: goal }, 200);
} catch (error) {
return handleError(c, error, 'Failed to archive program goal');
}
}
)
/**
* POST /program/:programId/goals/:goalId/evaluate
* Force a re-evaluation of one goal's per-learner statuses.
*/
.post(
'/:programId/goals/:goalId/evaluate',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZGoalParam),
async (c) => {
try {
const { goalId } = c.req.valid('param');
const result = await evaluateGoal(goalId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goal');
}
}
)
/**
* POST /program/:programId/goals/evaluate-all
* Force a re-evaluation of every active goal in the program.
*/
.post(
'/:programId/goals/evaluate-all',
authMiddleware,
programTeamMemberMiddleware,
zValidator('param', ZProgramParam),
async (c) => {
try {
const { programId } = c.req.valid('param');
const result = await evaluateProgramGoals(programId);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to evaluate program goals');
}
}
);
+37
View File
@@ -0,0 +1,37 @@
import {
clearSecondariesReadOnly,
findAccountsOverAllowance,
setSecondariesReadOnlyUntil
} from '@cio/db/queries/account';
import { WORKSPACES_INCLUDED } from '@cio/utils/plans';
const GRACE_PERIOD_DAYS = 7;
export type GraceCheckResult = {
scanned: number;
marked: Array<{ primaryOrgId: string; planName: string | null; workspaceCount: number; secondariesAffected: number }>;
};
/**
* Daily check: any account whose total workspace count exceeds the allowance
* for its current plan gets all of its secondary workspaces marked read-only
* for the next 7 days. The primary workspace is never locked.
*/
export async function runWorkspaceGraceCheck(): Promise<GraceCheckResult> {
const offenders = await findAccountsOverAllowance(WORKSPACES_INCLUDED);
const until = new Date(Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
const marked: GraceCheckResult['marked'] = [];
for (const offender of offenders) {
const affected = await setSecondariesReadOnlyUntil(offender.primaryOrgId, until);
marked.push({ ...offender, secondariesAffected: affected });
}
return { scanned: offenders.length, marked };
}
export async function clearWorkspaceGraceForAccount(primaryOrgId: string): Promise<number> {
return clearSecondariesReadOnly(primaryOrgId);
}
+4
View File
@@ -0,0 +1,4 @@
export * from './profile';
export * from './workspaces';
export * from './quota';
export * from './grace';
+84
View File
@@ -0,0 +1,84 @@
import * as schema from '@db/schema';
import { and, count, eq, gte, inArray, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@cio/db/drizzle';
import { listAccountOrgIds } from '@cio/db/queries/account';
export type AccountUsage = {
workspaceCount: number;
learnerCount: number;
tokensUsedThisMonth: number;
perWorkspace: Array<{
orgId: string;
learnerCount: number;
tokensUsedThisMonth: number;
}>;
};
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
export async function getAccountUsage(primaryOrgId: string): Promise<AccountUsage> {
const orgIds = await listAccountOrgIds(primaryOrgId);
if (orgIds.length === 0) {
return { workspaceCount: 0, learnerCount: 0, tokensUsedThisMonth: 0, perWorkspace: [] };
}
const since = startOfCurrentMonth().toISOString();
const [learnerRows, tokenRows] = await Promise.all([
db
.select({
organizationId: schema.organizationmember.organizationId,
c: count()
})
.from(schema.organizationmember)
.where(
and(
inArray(schema.organizationmember.organizationId, orgIds),
eq(schema.organizationmember.roleId, ROLE.STUDENT)
)
)
.groupBy(schema.organizationmember.organizationId),
db
.select({
orgId: schema.aiTokenUsage.orgId,
tokens: sql<number>`COALESCE(SUM(COALESCE(${schema.aiTokenUsage.costUnits}, ${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})), 0)`
})
.from(schema.aiTokenUsage)
.where(and(inArray(schema.aiTokenUsage.orgId, orgIds), gte(schema.aiTokenUsage.createdAt, since)))
.groupBy(schema.aiTokenUsage.orgId)
]);
const learnerByOrg = new Map<string, number>();
for (const row of learnerRows) {
learnerByOrg.set(row.organizationId, row.c);
}
const tokensByOrg = new Map<string, number>();
for (const row of tokenRows) {
if (row.orgId) tokensByOrg.set(row.orgId, Number(row.tokens) || 0);
}
const perWorkspace = orgIds.map((orgId) => ({
orgId,
learnerCount: learnerByOrg.get(orgId) ?? 0,
tokensUsedThisMonth: tokensByOrg.get(orgId) ?? 0
}));
const learnerCount = perWorkspace.reduce((sum, row) => sum + row.learnerCount, 0);
const tokensUsedThisMonth = perWorkspace.reduce((sum, row) => sum + row.tokensUsedThisMonth, 0);
return {
workspaceCount: orgIds.length,
learnerCount,
tokensUsedThisMonth,
perWorkspace
};
}
+127
View File
@@ -0,0 +1,127 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
countWorkspacesForAccount,
createSecondaryWorkspace,
deleteSecondaryWorkspace,
getAccountPrimary,
getActivePlanForPrimary,
listWorkspacesForAccount
} from '@cio/db/queries/account';
import { canCreateWorkspaces, getWorkspaceAllowance } from '@cio/utils/plans';
import { ROLE } from '@cio/utils/constants';
import type { TOrganization } from '@cio/db/types';
import { checkSiteNameExists } from '@cio/db/queries/organization';
export type AccountWorkspace = TOrganization & {
isPrimary: boolean;
};
async function resolvePrimaryOrThrow(activeOrgId: string): Promise<TOrganization> {
const primary = await getAccountPrimary(activeOrgId);
if (!primary) {
throw new AppError('Active organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
}
if (primary.id !== activeOrgId) {
throw new AppError(
'Account workspace operations must be performed from the primary workspace',
ErrorCodes.NOT_PRIMARY_WORKSPACE,
403
);
}
return primary;
}
export async function listAccountWorkspaces(activeOrgId: string): Promise<AccountWorkspace[]> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const rows = await listWorkspacesForAccount(primary.id);
return rows.map((row) => ({
...row,
isPrimary: row.parentOrganizationId === null
}));
}
export async function createWorkspaceForAccount(
activeOrgId: string,
ownerProfileId: string,
input: { name: string; siteName: string }
): Promise<AccountWorkspace> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
if (!canCreateWorkspaces(planName)) {
throw new AppError('Multi-workspace requires the Enterprise plan', ErrorCodes.PLAN_UPGRADE_REQUIRED, 402);
}
const allowance = getWorkspaceAllowance(planName);
const current = await countWorkspacesForAccount(primary.id);
if (current >= allowance) {
throw new AppError(
`Workspace limit reached (${allowance} per Enterprise account)`,
ErrorCodes.WORKSPACE_LIMIT_REACHED,
409
);
}
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
try {
const created = await createSecondaryWorkspace({
parentOrganizationId: primary.id,
name: input.name,
siteName: input.siteName,
ownerProfileId,
ownerRoleId: ROLE.ADMIN
});
return { ...created, isPrimary: false };
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && (error as { code: unknown }).code === '23505') {
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError(error instanceof Error ? error : new Error('Unknown error'), ErrorCodes.ORG_CREATE_FAILED, 500);
}
}
export async function deleteWorkspaceFromAccount(activeOrgId: string, targetWorkspaceId: string): Promise<void> {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const workspaces = await listWorkspacesForAccount(primary.id);
const target = workspaces.find((row) => row.id === targetWorkspaceId);
if (!target) {
throw new AppError('Workspace not found in account', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
if (!target.parentOrganizationId) {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
const result = await deleteSecondaryWorkspace(targetWorkspaceId);
if (!result.deleted) {
if (result.reason === 'IS_PRIMARY') {
throw new AppError('Cannot delete the primary workspace', ErrorCodes.WORKSPACE_IS_PRIMARY, 400);
}
throw new AppError('Workspace not found', ErrorCodes.WORKSPACE_NOT_FOUND, 404);
}
}
export async function getWorkspaceLimits(activeOrgId: string) {
const primary = await resolvePrimaryOrThrow(activeOrgId);
const planName = await getActivePlanForPrimary(primary.id);
const used = await countWorkspacesForAccount(primary.id);
const allowance = getWorkspaceAllowance(planName);
return {
planName,
canCreate: canCreateWorkspaces(planName) && used < allowance,
used,
allowance,
requiresUpgrade: !canCreateWorkspaces(planName)
};
}
@@ -1,5 +1,11 @@
import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant';
import {
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
LANDING_PAGE_SECTION_HTML_AGENT_HINT
} from '@cio/ai-assistant/tools';
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
@@ -56,7 +62,14 @@ export const updateContentParam = z.object({
export const questionSchema = z.object({
question: z.string().min(1),
questionTypeId: z.number().int().min(1).max(14).default(1),
questionTypeId: z
.number()
.int()
.min(1)
.max(QUESTION_TYPE_REGISTRY.length)
.describe(
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
),
points: z.number().min(0).default(1),
order: z.number().int().min(0),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
@@ -150,17 +163,36 @@ export const addQuestionsParam = z.object({
questions: z.array(questionSchema)
});
export const updateQuestionSettingsSchema = z
.object({
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
tolerance: z.number().min(0).optional(),
correctAnswers: z.array(z.string().min(1)).optional(),
template: z.string().min(1).optional(),
maxStars: z.number().int().min(1).optional(),
caseSensitive: z.boolean().optional(),
distractors: z.array(z.string().min(1)).optional()
})
.passthrough()
.refine((settings) => Object.keys(settings).length > 0, {
message: 'Provide at least one settings field'
})
.describe(
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
);
export const updateOptionSchema = z.object({
id: z.number().int().optional(),
label: z.string().min(1).optional(),
isCorrect: z.boolean().optional(),
settings: z.record(z.string(), z.unknown()).optional()
settings: updateQuestionSettingsSchema.optional()
});
export const updateQuestionPatchSchema = z.object({
export const updateQuestionPatchSchema = z
.object({
id: z.number().int(),
question: z.string().min(1).optional(),
questionTypeId: z.number().int().min(1).max(14).optional(),
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(),
exerciseSectionId: z
@@ -171,9 +203,22 @@ export const updateQuestionPatchSchema = z.object({
.describe(
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
),
settings: z.record(z.string(), z.unknown()).optional(),
settings: updateQuestionSettingsSchema.optional(),
options: z.array(updateOptionSchema).optional()
});
})
.refine(
(patch) =>
patch.question !== undefined ||
patch.questionTypeId !== undefined ||
patch.points !== undefined ||
patch.order !== undefined ||
patch.exerciseSectionId !== undefined ||
patch.settings !== undefined ||
patch.options !== undefined,
{
message: 'Provide at least one field to update besides id'
}
);
export const updateQuestionsParam = z.object({
exerciseId: z.string(),
@@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({
});
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
const agentTemplateFormFieldBase = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
placeholder: z.string().optional()
});
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
agentTemplateFormFieldBase.extend({
type: z.literal('select'),
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
})
]);
export const askTemplateQuestionsParam = z.object({
templateId: courseTemplateIdParam,
title: z.string().min(1),
description: z.string().optional(),
fields: z.array(agentTemplateFormFieldParam).min(1)
});
export const fetchDocumentationUrlParam = z.object({
url: z.string().url()
});
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
export const agentLessonTabsOrder = z.array(
@@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array(
})
);
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
lessonTabsOrder: agentLessonTabsOrder.optional()
lessonTabsOrder: agentLessonTabsOrder.optional(),
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
instructor: z
.object({
name: z.string().optional(),
role: z.string().optional(),
coursesNo: z.number().optional(),
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
imgUrl: z.string().optional()
})
.optional()
});
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
generateImage: z
.boolean()
.optional()
.describe(
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
),
imageQuery: z
.string()
.min(1)
.max(120)
.optional()
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
metadata: agentLandingPageMetadataUpdate.optional()
}).refine(
(data) =>
+26 -3
View File
@@ -3,7 +3,7 @@ import { getDocumentText } from '@api/services/agent/document';
import { redis } from '@api/utils/redis/redis';
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant';
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
type ResourceOwnershipRow = {
courseId: string | null;
@@ -137,7 +137,7 @@ type PlanMetadataMessage = {
};
};
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanFieldsSchema> | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PlanMetadataMessage;
@@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
continue;
}
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
if (parsedPlan.success) {
return parsedPlan.data;
@@ -158,3 +158,26 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
return undefined;
}
const COURSE_TEMPLATE_ID_SET = new Set<CourseTemplateId>(['product_101', 'product_onboarding', 'expert_on_x']);
export function getActiveCourseTemplateId(messages: unknown[]): CourseTemplateId | undefined {
for (const message of messages) {
const candidate = message as {
role?: string;
metadata?: { template?: { id?: string } };
};
if (candidate.role !== 'user') {
continue;
}
const id = candidate.metadata?.template?.id;
if (id && COURSE_TEMPLATE_ID_SET.has(id as CourseTemplateId)) {
return id as CourseTemplateId;
}
}
return undefined;
}
+134 -4
View File
@@ -2,23 +2,153 @@ import {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
saveChatMessages as saveDbChatMessages,
deleteChatConversation,
updateConversationTitle,
getChatHistory,
saveChatHistory,
saveChatHistory as saveDbChatHistory,
deleteChatHistory
} from '@cio/db/queries/agent';
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function serializeValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch {
return '';
}
}
function getToolName(part: Record<string, unknown>): string | null {
const type = part.type;
if (type === 'tool-invocation') {
const toolInvocation = part.toolInvocation;
return isRecord(toolInvocation) && typeof toolInvocation.toolName === 'string' ? toolInvocation.toolName : null;
}
if (typeof type === 'string' && type.startsWith('tool-')) {
return type.slice(5);
}
return null;
}
function buildTruncatedOutput(output: unknown): unknown {
const serialized = serializeValue(output);
if (serialized.length <= MAX_PERSISTED_TOOL_OUTPUT_CHARS) {
return output;
}
const preview = serialized.slice(0, MAX_PERSISTED_TOOL_OUTPUT_CHARS) + '...[truncated]';
if (typeof output === 'string') {
return preview;
}
if (!isRecord(output)) {
return preview;
}
const preservedScalars = Object.fromEntries(
Object.entries(output).filter(
([, value]) => value == null || ['string', 'number', 'boolean'].includes(typeof value)
)
);
return {
...preservedScalars,
truncated: true,
preview
};
}
function sanitizeMessagePart(part: unknown): unknown | null {
if (!isRecord(part)) {
return part;
}
const type = typeof part.type === 'string' ? part.type : '';
if (DROPPED_PART_TYPES.has(type)) {
return null;
}
const toolName = getToolName(part);
if (!toolName || TOOLS_WITH_FULL_OUTPUT.has(toolName)) {
return part;
}
if (part.type === 'tool-invocation' && isRecord(part.toolInvocation)) {
return {
...part,
toolInvocation: {
...part.toolInvocation,
result: buildTruncatedOutput(part.toolInvocation.result)
}
};
}
if (part.state !== 'output-available' || part.output == null) {
return part;
}
return {
...part,
output: buildTruncatedOutput(part.output)
};
}
function sanitizeChatMessages(messages: unknown[]): unknown[] {
return messages.map((message) => {
if (!isRecord(message) || !Array.isArray(message.parts)) {
return message;
}
const sanitizedParts = message.parts.map(sanitizeMessagePart).filter((part) => part !== null);
return {
...message,
parts: sanitizedParts
};
});
}
export async function saveChatMessages(
conversationId: string,
userId: string,
messages: unknown[],
title?: string
): Promise<void> {
await saveDbChatMessages(conversationId, userId, sanitizeChatMessages(messages), title);
}
async function persistChatHistory(courseId: string, userId: string, messages: unknown[]): Promise<void> {
await saveDbChatHistory(courseId, userId, sanitizeChatMessages(messages));
}
export {
listChatConversations,
getChatConversation,
createChatConversation,
saveChatMessages,
deleteChatConversation,
updateConversationTitle,
// Legacy compat
getChatHistory as fetchChatHistory,
saveChatHistory as persistChatHistory,
persistChatHistory,
deleteChatHistory as clearChatHistory
};
+53 -4
View File
@@ -28,6 +28,7 @@ import {
} from '@api/services/agent/chat-context';
import {
addQuestionsParam,
askTemplateQuestionsParam,
coursePlanParam,
createExerciseParam,
createExerciseSectionParam,
@@ -35,6 +36,7 @@ import {
createSectionParam,
emptyParam,
exerciseReadParam,
fetchDocumentationUrlParam,
goLiveParam,
lessonReadParam,
reorderContentParam,
@@ -46,6 +48,7 @@ import {
updateQuestionsParam,
updateSectionParam
} from '@api/services/agent/agent-tool-schemas';
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
if (value == null) return value;
@@ -184,7 +187,7 @@ async function executeAgentTool<TArgs, TResult>(
}
}
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
export function buildAgentTools(orgId: string, userId: string, courseId: string, priorMessages: unknown[]) {
return {
get_course_structure: tool({
description:
@@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
});
return {
lessonId: args.lessonId,
lessonTitle: lesson.title,
locale: args.locale,
contentLength: normalizedContent.length,
updated: true
@@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
];
await updateExerciseService(args.exerciseId, { questions: allQuestions });
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
return {
exerciseId: args.exerciseId,
exerciseTitle: existingExercise.title,
addedCount: args.questions.length,
totalCount: allQuestions.length
};
});
}
}),
@@ -545,7 +554,11 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
await updateExerciseService(args.exerciseId, { questions: merged });
return { exerciseId: args.exerciseId, updatedCount: merged.length };
return {
exerciseId: args.exerciseId,
exerciseTitle: existing.title,
updatedCount: merged.length
};
});
}
}),
@@ -586,7 +599,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
update_course_landing_page: tool({
description:
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.',
inputSchema: updateCourseLandingPageParam,
execute: async (args) => {
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
@@ -650,6 +663,42 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
return plan;
});
}
}),
ask_template_questions: tool({
description:
'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.',
inputSchema: askTemplateQuestionsParam,
execute: async (args) => {
return executeAgentTool(
'ask_template_questions',
{ orgId, userId, courseId, args },
async () =>
({
awaiting_user: true as const,
templateId: args.templateId,
title: args.title,
description: args.description,
fields: args.fields
}) as const
);
}
}),
fetch_documentation_url: tool({
description:
'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.',
inputSchema: fetchDocumentationUrlParam,
execute: async (args) => {
return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => {
return fetchDocumentationUrl({
url: args.url,
orgId,
courseId,
priorMessages
});
});
}
})
};
}
+38
View File
@@ -0,0 +1,38 @@
import { randomUUID } from 'node:crypto';
import { AppError } from '@api/utils/errors';
import { ErrorCodes } from '@cio/utils/constants';
import { getChatConversation, saveChatMessages } from '@api/services/agent/chat-history';
import { summarizeConversation, type SummarizeOptions } from '@api/services/agent/summarize';
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
const conversation = await getChatConversation(conversationId, userId);
if (!conversation) {
throw new AppError('Conversation not found', 'CONVERSATION_NOT_FOUND', 404);
}
const storedMessages = (conversation.messages ?? []) as unknown[];
if (storedMessages.length === 0) {
throw new AppError('No messages to compact', ErrorCodes.NO_MESSAGES_TO_COMPACT, 400);
}
const summary = await summarizeConversation({ messages: storedMessages as SummarizeOptions['messages'] });
const seed = {
id: randomUUID(),
role: 'user' as const,
parts: [{ type: 'text' as const, text: summary }],
metadata: {
compaction: {
compactedAt: new Date().toISOString(),
originalMessageCount: storedMessages.length
}
}
};
await saveChatMessages(conversationId, userId, [seed]);
return { messages: [seed] };
}
+343
View File
@@ -0,0 +1,343 @@
import { createHash } from 'node:crypto';
import { isIPv4, isIPv6 } from 'node:net';
import { AppError } from '@api/utils/errors';
import { env } from '@api/config/env';
import { redis, logRedisUnavailableOnce } from '@api/utils/redis/redis';
import { isOrgOnPaidPlan } from '@api/services/agent/usage';
import { ErrorCodes } from '@cio/utils/constants/error-codes';
const JINA_READER_PREFIX = 'https://r.jina.ai/';
const MAX_MARKDOWN_BYTES = 150 * 1024;
const CACHE_TTL_SEC = 7 * 24 * 3600;
const QUOTA_TTL_SEC = 25 * 3600;
export type FetchDocumentationUrlResult = {
url: string;
pageTitle: string;
content: string;
links: string[];
contentTokens: number;
fetchedAt: string;
cacheHit: boolean;
};
type CachedFetchPayload = {
url: string;
pageTitle: string;
rawMarkdown: string;
links: string[];
fetchedAt: string;
};
function isForbiddenHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost')) {
return true;
}
if (isIPv4(host) || isIPv6(host)) {
return true;
}
return false;
}
export function assertFetchableDocumentationUrl(rawUrl: string): URL {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new AppError('Invalid documentation URL', 'INVALID_DOCUMENTATION_URL', 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new AppError('Documentation URL must use http or https', 'INVALID_DOCUMENTATION_URL', 400);
}
if (isForbiddenHostname(parsed.hostname)) {
throw new AppError('Documentation URL hostname is not allowed', 'INVALID_DOCUMENTATION_URL', 400);
}
return parsed;
}
function extractSameOriginLinks(markdown: string, origin: URL): string[] {
const seen = new Set<string>();
const out: string[] = [];
const re = /\[[^\]]*\]\((https?:[^)\s]+)\)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
try {
const linkUrl = new URL(match[1]);
if (linkUrl.protocol !== 'http:' && linkUrl.protocol !== 'https:') {
continue;
}
if (linkUrl.hostname !== origin.hostname) {
continue;
}
const normalized = linkUrl.href;
if (!seen.has(normalized)) {
seen.add(normalized);
out.push(normalized);
}
} catch {
// skip invalid URL in markdown
}
}
return out;
}
function guessPageTitle(markdown: string, fallbackUrl: string): string {
const trimmed = markdown.trim();
const firstLine = trimmed.split('\n')[0] ?? '';
if (firstLine.startsWith('# ')) {
return firstLine.slice(2).trim();
}
if (firstLine.startsWith('#')) {
return firstLine.replace(/^#+\s*/, '').trim();
}
try {
return new URL(fallbackUrl).hostname;
} catch {
return 'Documentation';
}
}
function truncateMarkdown(body: string): { text: string; truncated: boolean } {
if (body.length <= MAX_MARKDOWN_BYTES) {
return { text: body, truncated: false };
}
const suffix = '\n\n[Truncated: content exceeded 150 KB]';
return {
text: body.slice(0, MAX_MARKDOWN_BYTES - suffix.length) + suffix,
truncated: true
};
}
function wrapUntrustedMarkdown(url: string, markdown: string): string {
const safeSrc = url.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
return `<external_untrusted_document src="${safeSrc}">\n${markdown}\n</external_untrusted_document>`;
}
export function countCompletedFetchDocumentationCalls(messages: unknown[]): number {
let count = 0;
for (const msg of messages as { parts?: unknown[] }[]) {
const parts = msg.parts;
if (!parts) {
continue;
}
for (const part of parts) {
if (!part || typeof part !== 'object') {
continue;
}
const record = part as Record<string, unknown>;
const type = typeof record.type === 'string' ? record.type : '';
let toolName: string | null = null;
if (type === 'tool-invocation') {
toolName = (record.toolInvocation as { toolName?: string } | undefined)?.toolName ?? null;
} else if (type.startsWith('tool-')) {
toolName = type.slice(5);
}
if (toolName !== 'fetch_documentation_url') {
continue;
}
const state = record.state;
if (state === 'result' || state === 'output-available' || state === 'output-error') {
count += 1;
}
}
}
return count;
}
async function redisSafeGet(key: string): Promise<string | null> {
try {
if (!redis.isOpen) {
return null;
}
return await redis.get(key);
} catch (error) {
logRedisUnavailableOnce('fetch-url cache get failed', error);
return null;
}
}
async function redisSafeSet(key: string, value: string, ttlSec: number): Promise<void> {
try {
if (!redis.isOpen) {
return;
}
await redis.set(key, value, { EX: ttlSec });
} catch (error) {
logRedisUnavailableOnce('fetch-url cache set failed', error);
}
}
async function incrementOrgDailyFetchQuota(orgId: string, maxPerDay: number): Promise<void> {
const dayKey = new Date().toISOString().slice(0, 10);
const key = `agent:fetch_url:quota:${orgId}:${dayKey}`;
try {
if (!redis.isOpen) {
return;
}
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, QUOTA_TTL_SEC);
}
if (count > maxPerDay) {
throw new AppError(
'Daily documentation fetch quota exceeded for this organization',
'AGENT_FETCH_QUOTA_EXCEEDED',
429
);
}
} catch (error) {
if (error instanceof AppError) {
throw error;
}
logRedisUnavailableOnce('fetch-url quota redis failed', error);
}
}
async function fetchMarkdownFromJina(url: string): Promise<{ markdown: string; status: number }> {
const readerHref = `${JINA_READER_PREFIX}${url}`;
const headers: Record<string, string> = {};
if (env.JINA_API_KEY) {
headers.Authorization = `Bearer ${env.JINA_API_KEY}`;
}
let response: Response;
try {
response = await fetch(readerHref, {
headers,
redirect: 'follow'
});
} catch {
throw new AppError('Failed to reach documentation reader', 'DOCUMENTATION_FETCH_FAILED', 502);
}
const status = response.status;
const bytesHint = response.headers.get('content-length');
if (!response.ok) {
console.info('[fetch_documentation_url]', { url, status, bytes: bytesHint });
throw new AppError(`Documentation fetch failed with status ${status}`, 'DOCUMENTATION_FETCH_FAILED', 502);
}
const markdown = await response.text();
console.info('[fetch_documentation_url]', { url, status, bytes: String(markdown.length) });
return { markdown, status };
}
export async function fetchDocumentationUrl(params: {
url: string;
orgId: string;
courseId: string;
priorMessages: unknown[];
}): Promise<FetchDocumentationUrlResult> {
const { url, orgId, courseId, priorMessages } = params;
const normalizedUrl = assertFetchableDocumentationUrl(url);
const normalizedHref = normalizedUrl.href;
const priorFetches = countCompletedFetchDocumentationCalls(priorMessages);
if (priorFetches >= env.AGENT_MAX_FETCHES_PER_CONVERSATION) {
throw new AppError('Maximum documentation fetches per conversation reached', 'AGENT_FETCH_CONVERSATION_LIMIT', 429);
}
const paid = await isOrgOnPaidPlan(orgId);
if (!paid) {
throw new AppError('Documentation fetching requires a paid plan', ErrorCodes.UPGRADE_REQUIRED, 403);
}
const cacheKeyRaw = `${orgId}:${normalizedHref}`;
const cacheKey = `agent:fetch_url:${createHash('sha256').update(cacheKeyRaw).digest('hex')}`;
const cachedRaw = await redisSafeGet(cacheKey);
if (cachedRaw) {
try {
const cached = JSON.parse(cachedRaw) as CachedFetchPayload;
const truncated = truncateMarkdown(cached.rawMarkdown);
return {
url: cached.url,
pageTitle: cached.pageTitle,
content: wrapUntrustedMarkdown(cached.url, truncated.text),
links: cached.links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt: cached.fetchedAt,
cacheHit: true
};
} catch {
// fall through to refetch
}
}
await incrementOrgDailyFetchQuota(orgId, env.AGENT_MAX_FETCHES_PER_ORG_PER_DAY);
const { markdown } = await fetchMarkdownFromJina(normalizedHref);
const truncated = truncateMarkdown(markdown);
const pageTitle = guessPageTitle(truncated.text, normalizedHref);
const links = extractSameOriginLinks(truncated.text, normalizedUrl);
const fetchedAt = new Date().toISOString();
const cachePayload: CachedFetchPayload = {
url: normalizedHref,
pageTitle,
rawMarkdown: truncated.text,
links,
fetchedAt
};
await redisSafeSet(cacheKey, JSON.stringify(cachePayload), CACHE_TTL_SEC);
void courseId;
return {
url: normalizedHref,
pageTitle,
content: wrapUntrustedMarkdown(normalizedHref, truncated.text),
links,
contentTokens: Math.ceil(truncated.text.length / 4),
fetchedAt,
cacheHit: false
};
}
@@ -0,0 +1,138 @@
/**
* Anthropic strictly requires every `tool_use` block to be followed in the very
* next message by a `tool_result` block with a matching id. OpenAI tolerates
* mismatches; Anthropic 400s.
*
* The break-shapes this catches:
* (a) `convertToModelMessages` splits an assistant UIMessage with parts
* `[tool-call, text, text]` into TWO assistant ModelMessages plus a tool
* message — the wedged second assistant message separates tool_use from
* tool_result.
* (b) `trimMessageHistory` slices the array between an assistant tool-call
* turn and its tool-result message.
* (c) A tool call was aborted mid-stream and persisted without a result.
*
* Strategy: walk the ModelMessage[] once. For every assistant message that has
* tool-call parts, **immediately** emit a tool message containing matching
* tool-result parts — pulled from anywhere later in the array, or synthesized
* as an error result if missing entirely. Tool messages whose results have
* already been claimed by an earlier assistant turn are dropped (or trimmed)
* to avoid duplicates.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type ToolCallPart = { type: 'tool-call' | 'dynamic-tool-call'; toolCallId: string; toolName: string };
type ToolResultPart = {
type: 'tool-result' | 'dynamic-tool-result';
toolCallId: string;
toolName?: string;
output?: unknown;
};
function isToolCallPart(part: unknown): part is ToolCallPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-call' || type === 'dynamic-tool-call';
}
function isToolResultPart(part: unknown): part is ToolResultPart {
const type = (part as { type?: unknown })?.type;
return type === 'tool-result' || type === 'dynamic-tool-result';
}
function syntheticErrorResult(tc: ToolCallPart): ToolResultPart {
return {
type: 'tool-result',
toolCallId: tc.toolCallId,
toolName: tc.toolName,
output: { type: 'error-text', value: 'Tool call was interrupted; no result captured.' }
};
}
/**
* Anthropic accepts assistant content in the canonical shape `[text*, tool_use*]`
* (text/reasoning first, then tool_uses at the end). When the assistant content
* has text AFTER tool_use blocks (`[tool_use, text, text]`), Anthropic's strict
* validator can reject it with the same "tool_result missing immediately after"
* error even though the next message IS a tool message — because it doesn't
* recognise the assistant turn as ending on tool_use. Reorder so tool-calls
* always come last within each assistant message.
*/
function reorderAssistantContent(content: any[]): any[] {
const toolCalls: any[] = [];
const nonToolCalls: any[] = [];
for (const part of content) {
if (isToolCallPart(part)) {
toolCalls.push(part);
} else {
nonToolCalls.push(part);
}
}
// Only return a new array if we actually moved something.
if (toolCalls.length === 0 || nonToolCalls.length === 0) return content;
return [...nonToolCalls, ...toolCalls];
}
export function sanitizeDanglingToolCalls(messages: any[]): any[] {
// Pass 1 — index every tool-result we can find anywhere in the conversation,
// keyed by toolCallId. If duplicates exist, last one wins (most recent).
const toolResultsByCallId = new Map<string, ToolResultPart>();
for (const m of messages) {
if (m?.role !== 'tool' || !Array.isArray(m.content)) continue;
for (const part of m.content) {
if (isToolResultPart(part)) {
toolResultsByCallId.set(part.toolCallId, part);
}
}
}
// Pass 2 — rebuild. After each assistant message with tool-calls, inject a
// tool message whose results match those calls in the same order. Skip the
// original tool messages whose parts we've already claimed. Also reorder
// assistant content so tool-calls always sit at the END.
const claimedResultIds = new Set<string>();
const result: any[] = [];
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i];
// Filter or drop tool messages whose results were already pulled forward.
if (message?.role === 'tool' && Array.isArray(message.content)) {
const remaining = message.content.filter(
(part: unknown) => !isToolResultPart(part) || !claimedResultIds.has(part.toolCallId)
);
if (remaining.length > 0) {
result.push({ ...message, content: remaining });
}
continue;
}
if (message?.role !== 'assistant' || !Array.isArray(message.content)) {
result.push(message);
continue;
}
const reorderedContent = reorderAssistantContent(message.content);
const reorderedMessage = reorderedContent === message.content ? message : { ...message, content: reorderedContent };
result.push(reorderedMessage);
const toolCalls = reorderedContent.filter(isToolCallPart);
if (toolCalls.length === 0) continue;
const resultParts: ToolResultPart[] = toolCalls.map((tc: ToolCallPart) => {
claimedResultIds.add(tc.toolCallId);
const existing = toolResultsByCallId.get(tc.toolCallId);
return existing ?? syntheticErrorResult(tc);
});
result.push({ role: 'tool', content: resultParts });
}
return result;
}
@@ -0,0 +1,291 @@
import { and, asc, eq, ilike, or } from 'drizzle-orm';
import { tool } from 'ai';
import { z } from 'zod';
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import type { AiTutorSettings } from '@cio/ai-assistant';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { listCourseSections } from '@api/services/course/section';
import { AppError } from '@api/utils/errors';
import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
/**
* Student agent tools — read-only, course-scoped.
*
* Every tool re-verifies the resource belongs to `courseId` so a poisoned model
* cannot reach into other courses or other learners' data. Exercise reads strip
* answer keys and per-question scoring data before returning.
*/
const SEARCH_SNIPPET_RADIUS = 80;
function logToolEvent(
phase: 'start' | 'success' | 'error',
toolName: string,
details: { orgId: string; userId: string; courseId: string; args?: unknown; error?: unknown }
) {
const base = `[student-tool:${phase}] ${toolName}`;
if (phase === 'error') {
console.error(base, {
courseId: details.courseId,
userId: details.userId,
args: details.args,
error: details.error instanceof Error ? details.error.message : details.error
});
return;
}
console.info(base, {
courseId: details.courseId,
userId: details.userId
});
}
async function executeStudentTool<T>(
toolName: string,
ctx: { orgId: string; userId: string; courseId: string; args?: unknown },
execute: () => Promise<T>
): Promise<T> {
trackAgentEvent(AgentEvent.TOOL_CALLED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName
});
logToolEvent('start', toolName, ctx);
try {
const result = await execute();
trackAgentEvent(AgentEvent.TOOL_COMPLETED, {
orgId: ctx.orgId,
userId: ctx.userId,
courseId: ctx.courseId,
toolName,
success: true
});
logToolEvent('success', toolName, ctx);
return result;
} catch (error) {
logToolEvent('error', toolName, { ...ctx, error });
if (error instanceof AppError) throw error;
throw error instanceof Error ? error : new Error(String(error));
}
}
function stripAnswerKeysFromQuestion(question: {
id: number | string;
title: string;
questionTypeId: number;
order: number | null;
points: number | null;
options?: { label: string | null }[];
}): {
id: number | string;
question: string;
questionTypeId: number;
order: number | null;
options: { label: string }[];
} {
return {
id: question.id,
question: question.title,
questionTypeId: question.questionTypeId,
order: question.order,
options: (question.options ?? []).filter((o) => o.label != null).map((o) => ({ label: o.label as string }))
};
}
function makeSnippet(text: string, query: string, radius: number = SEARCH_SNIPPET_RADIUS): string {
if (!text) return '';
const normalized = text
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const lower = normalized.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) return normalized.slice(0, radius * 2) + (normalized.length > radius * 2 ? '…' : '');
const start = Math.max(0, idx - radius);
const end = Math.min(normalized.length, idx + query.length + radius);
const prefix = start > 0 ? '…' : '';
const suffix = end < normalized.length ? '…' : '';
return `${prefix}${normalized.slice(start, end)}${suffix}`;
}
const listCourseOutlineParam = z.object({});
const readLessonParam = z.object({ lessonId: z.string() });
const readExerciseParam = z.object({ exerciseId: z.string() });
const searchCourseParam = z.object({
query: z.string().min(1).max(200),
limit: z.number().int().min(1).max(20).default(8)
});
export function buildStudentAgentTools(orgId: string, userId: string, courseId: string, _settings: AiTutorSettings) {
return {
list_course_outline: tool({
description:
'List the course outline (sections with their lessons and exercises). The courseId is automatically scoped — do not pass it. Returns titles and ids only.',
inputSchema: listCourseOutlineParam,
execute: async () => {
return executeStudentTool('list_course_outline', { orgId, userId, courseId }, async () => {
const [sections, lessons, exercises] = await Promise.all([
listCourseSections(courseId),
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
sectionId: schema.lesson.sectionId,
order: schema.lesson.order
})
.from(schema.lesson)
.where(eq(schema.lesson.courseId, courseId))
.orderBy(asc(schema.lesson.order)),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
lessonId: schema.exercise.lessonId,
sectionId: schema.exercise.sectionId,
order: schema.exercise.order
})
.from(schema.exercise)
.where(eq(schema.exercise.courseId, courseId))
.orderBy(asc(schema.exercise.order))
]);
return { sections, lessons, exercises };
});
}
}),
read_lesson: tool({
description:
'Read the title and body of a specific lesson in the current course. Use this when the learner refers to a lesson that is not already loaded in your context.',
inputSchema: readLessonParam,
execute: async (args) => {
return executeStudentTool('read_lesson', { orgId, userId, courseId, args }, async () => {
await verifyLessonBelongsToCourse(args.lessonId, courseId);
const lesson = await getLesson(args.lessonId);
const lessonWithLangs = lesson as {
id: string;
title: string;
note?: string | null;
lessonLanguages?: Array<{ locale: string; content: string | null }>;
};
const content = lessonWithLangs.lessonLanguages?.find((ll) => ll.locale === 'en')?.content ?? null;
return {
id: lesson.id,
title: lesson.title,
content,
note: lessonWithLangs.note ?? null
};
});
}
}),
read_exercise: tool({
description:
'Read an exercise prompt — the question text and options visible to a student. Answer keys, correct flags, and marking schemes are stripped before returning.',
inputSchema: readExerciseParam,
execute: async (args) => {
return executeStudentTool('read_exercise', { orgId, userId, courseId, args }, async () => {
await verifyExerciseBelongsToCourse(args.exerciseId, courseId);
const exercise = await getExercise(args.exerciseId);
const questions = (exercise.questions ?? []).map((q) =>
stripAnswerKeysFromQuestion({
id: q.id,
title: q.title,
questionTypeId: q.questionTypeId,
order: q.order,
points: q.points ?? null,
options: q.options ?? []
})
);
return {
id: exercise.id,
title: exercise.title,
description: exercise.description,
questions
};
});
}
}),
search_course: tool({
description:
'Search this course for lessons and exercises whose title or body contains the query. Returns up to `limit` ranked snippets with ids you can pass to read_lesson / read_exercise.',
inputSchema: searchCourseParam,
execute: async (args) => {
return executeStudentTool('search_course', { orgId, userId, courseId, args }, async () => {
const pattern = `%${args.query}%`;
const limit = args.limit ?? 8;
const [lessonMatches, exerciseMatches] = await Promise.all([
db
.select({
id: schema.lesson.id,
title: schema.lesson.title,
content: schema.lessonLanguage.content
})
.from(schema.lesson)
.leftJoin(
schema.lessonLanguage,
and(eq(schema.lessonLanguage.lessonId, schema.lesson.id), eq(schema.lessonLanguage.locale, 'en'))
)
.where(
and(
eq(schema.lesson.courseId, courseId),
or(ilike(schema.lesson.title, pattern), ilike(schema.lessonLanguage.content, pattern))
)
)
.limit(limit),
db
.select({
id: schema.exercise.id,
title: schema.exercise.title,
description: schema.exercise.description
})
.from(schema.exercise)
.where(
and(
eq(schema.exercise.courseId, courseId),
or(ilike(schema.exercise.title, pattern), ilike(schema.exercise.description, pattern))
)
)
.limit(limit)
]);
const lessonsResult = lessonMatches.map((lm) => ({
type: 'lesson' as const,
id: lm.id,
title: lm.title,
snippet: makeSnippet(lm.content ?? lm.title ?? '', args.query)
}));
const exercisesResult = exerciseMatches.map((em) => ({
type: 'exercise' as const,
id: em.id,
title: em.title,
snippet: makeSnippet(em.description ?? em.title ?? '', args.query)
}));
const merged = [...lessonsResult, ...exercisesResult].slice(0, limit);
return { query: args.query, results: merged };
});
}
})
};
}
export type StudentAgentTools = ReturnType<typeof buildStudentAgentTools>;
+140
View File
@@ -0,0 +1,140 @@
import { generateText } from 'ai';
import { createModel, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AppError } from '@api/utils/errors';
const SUMMARIZE_SYSTEM_PROMPT = `You are summarizing a multi-turn ClassroomIO course-creation chat so the assistant can pick up exactly where it left off in a new conversation.
Write the summary as a first-person user message to the assistant. It must be DETAILED — assume the receiving assistant has zero prior context.
Required structure (use these exact section headings, written in plain text without markdown):
1. Course context: course title, intended audience, primary outcomes, and the active template flow (Product 101, Product Onboarding, Expert on X, or other) if any.
2. Template form answers: every answered field by key and value, including the chosen depth tier (light / balanced / deep_doc) and any documentation URL provided.
3. Documentation fetched: list every URL that was fetched via fetch_documentation_url and a one-line note on what was learned from each (key product surface area, vocabulary, workflows). Do NOT skip this section if any docs were fetched.
4. Plan status: whether a course plan has been generated, the plan title and section count, and whether it was approved, edited, or still pending changes.
5. Implementation progress: which sections / lessons / exercises were already created (by title), and any tool errors hit.
6. Open decisions: anything the teacher said they still wanted to do, change, or get an answer on.
7. Continue by: an explicit next action for the assistant (e.g. "Fetch the remaining linked docs and then call generate_course_plan", or "Resume implementing from section 4").
Length target: at least 250 words; expand as needed to capture detail. Do not write a high-level paragraph; write the structured handoff above. Never abbreviate or omit sections — write "(none)" if a section is empty.`;
interface InboundMessagePart {
type: string;
text?: string;
toolName?: string;
state?: string;
input?: unknown;
output?: unknown;
}
interface InboundMessage {
role: string;
content?: string;
parts?: InboundMessagePart[];
metadata?: Record<string, unknown> | null | undefined;
}
export interface SummarizeOptions {
messages: InboundMessage[];
}
function truncate(input: string, max: number): string {
if (input.length <= max) return input;
return `${input.slice(0, max)}…`;
}
function stringifySafely(value: unknown): string {
try {
return typeof value === 'string' ? value : JSON.stringify(value);
} catch {
return '[unserializable]';
}
}
function partsToText(message: InboundMessage): string {
const lines: string[] = [];
if (message.content && message.content.trim().length > 0) {
lines.push(message.content.trim());
}
for (const part of message.parts ?? []) {
if (part.type === 'text' && part.text) {
lines.push(part.text.trim());
continue;
}
if (part.type === 'tool-invocation' || part.type?.startsWith('tool-')) {
const toolName = part.toolName ?? (part.type.startsWith('tool-') ? part.type.slice(5) : 'unknown_tool');
const inputText = part.input !== undefined ? truncate(stringifySafely(part.input), 600) : '';
const outputText = part.output !== undefined ? truncate(stringifySafely(part.output), 1500) : '';
const segments: string[] = [`[tool:${toolName}]`];
if (inputText) {
segments.push(`input=${inputText}`);
}
if (outputText) {
segments.push(`output=${outputText}`);
}
lines.push(segments.join(' '));
}
}
const metadataTemplate = message.metadata?.template;
if (metadataTemplate && typeof metadataTemplate === 'object') {
lines.push(`[template-action] ${truncate(stringifySafely(metadataTemplate), 600)}`);
}
return lines.join('\n');
}
const MAX_CONVERSATION_CHARS = 60_000;
const MAX_OUTPUT_TOKENS = 2_000;
export async function summarizeConversation(options: SummarizeOptions): Promise<string> {
const providerConfig = pickAnyConfiguredProvider();
if (!providerConfig) {
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
}
const model = createModel(providerConfig);
const conversationLines: string[] = [];
for (const message of options.messages ?? []) {
const role = message.role === 'user' ? 'User' : 'Assistant';
const body = partsToText(message);
if (!body.trim()) {
continue;
}
conversationLines.push(`${role}: ${body}`);
}
const conversationText = conversationLines.join('\n\n');
// Prefer keeping the END of the transcript when truncating — recent context is most
// valuable for "continue from here" continuations.
const truncatedText =
conversationText.length > MAX_CONVERSATION_CHARS
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
: conversationText;
const result = await generateText({
model,
system: SUMMARIZE_SYSTEM_PROMPT,
prompt: `Here is the conversation to summarize. Tool calls appear as [tool:name] input=… output=… lines.\n\n${truncatedText}\n\nReturn the structured handoff message described in the system prompt.`,
maxOutputTokens: MAX_OUTPUT_TOKENS,
maxRetries: 0
});
return result.text.trim();
}
@@ -0,0 +1,99 @@
import { generateText } from 'ai';
import { AIProvider, type AIProviderConfig } from '@cio/ai-assistant';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
const TEXT_GEN_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
function createTextGenModel(config: AIProviderConfig) {
const modelName = TEXT_GEN_MODELS[config.provider];
switch (config.provider) {
case AIProvider.OPENAI: {
const openai = createOpenAI({ apiKey: config.apiKey });
return openai(modelName);
}
case AIProvider.ANTHROPIC: {
const anthropic = createAnthropic({ apiKey: config.apiKey });
return anthropic(modelName);
}
case AIProvider.GOOGLE: {
const google = createGoogleGenerativeAI({ apiKey: config.apiKey });
return google(modelName);
}
case AIProvider.MOONSHOT: {
const moonshot = createOpenAI({ apiKey: config.apiKey, baseURL: 'https://api.moonshot.cn/v1' });
return moonshot(modelName);
}
default:
throw new Error(`Unsupported AI provider: ${config.provider}`);
}
}
export interface GenerateFieldTextResult {
text: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
modelName: string;
}
export async function generateFieldText(
prompt: string,
tone: string,
format: 'plain' | 'html',
context: string | undefined,
providerConfig: AIProviderConfig
): Promise<GenerateFieldTextResult> {
const model = createTextGenModel(providerConfig);
const modelName = TEXT_GEN_MODELS[providerConfig.provider];
const contextLine = context ? `Context: ${context}.` : '';
const formatLine =
format === 'html'
? 'Return valid HTML (use <p>, <ul>, <li>, <strong> tags as appropriate). Never use heading tags (h1, h2, h3, h4, h5, h6). No surrounding code blocks or markdown.'
: 'Return plain text only — no HTML tags, no markdown, no quotes, no labels.';
const systemPrompt = [
'You are a content writer helping build a landing page for an online course platform.',
`Write text that matches the following description. Use a ${tone} tone.`,
contextLine,
formatLine
]
.filter(Boolean)
.join(' ');
const { text, usage } = await generateText(
providerConfig.provider === AIProvider.GOOGLE
? {
model,
system: systemPrompt,
prompt: prompt.slice(0, 1000),
maxOutputTokens: 512,
maxRetries: 0,
providerOptions: {
google: { thinkingConfig: { thinkingBudget: 0 } }
}
}
: {
model,
system: systemPrompt,
prompt: prompt.slice(0, 1000),
maxOutputTokens: 512,
maxRetries: 0
}
);
return {
text: text.trim(),
usage: {
promptTokens: usage.inputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
totalTokens: usage.totalTokens ?? 0
},
modelName
};
}
@@ -11,7 +11,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
const TITLE_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
[AIProvider.MOONSHOT]: 'kimi-k2.6'
};
@@ -55,7 +55,7 @@ export async function generateConversationTitle(
const truncatedMessage = firstMessageText.slice(0, 1000);
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
// Gemini 3.x counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
const { text } = await generateText(
providerConfig.provider === AIProvider.GOOGLE
? {
@@ -0,0 +1,88 @@
import { defaultAiTutorSettings, mergeAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
import {
getCourseAiTutorOverride,
getOrgAiTutorSettings,
updateCourseAiTutorOverride,
updateOrgAiTutorSettings
} from '@cio/db/queries/agent';
import { AppError } from '@api/utils/errors';
/**
* Resolved AI tutor settings for a given (org, course) pair.
*
* Merge order (later overrides earlier):
* defaultAiTutorSettings → org.aiTutorSettings → course.metadata.aiTutor
*
* If the course override has `inheritFromOrg !== false`, the org defaults apply
* for every field the course did not explicitly set. The course can still flip
* individual fields (including `enabled`).
*/
export async function getEffectiveAiTutorSettings(orgId: string, courseId: string): Promise<AiTutorSettings> {
const [orgSettings, courseOverride] = await Promise.all([
getOrgAiTutorSettings(orgId),
getCourseAiTutorOverride(courseId)
]);
const orgResolved = mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
if (!courseOverride) return orgResolved;
if (courseOverride.inheritFromOrg === true) return orgResolved;
const { inheritFromOrg: _drop, ...override } = courseOverride;
return mergeAiTutorSettings(orgResolved, override as Partial<AiTutorSettings>);
}
export async function getResolvedOrgAiTutorSettings(orgId: string): Promise<AiTutorSettings> {
const orgSettings = await getOrgAiTutorSettings(orgId);
return mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
}
export async function updateOrgAiTutorSettingsService(
orgId: string,
patch: Partial<AiTutorSettings>
): Promise<AiTutorSettings> {
const updated = await updateOrgAiTutorSettings(orgId, patch);
if (!updated) {
throw new AppError('Organization not found', 'ORGANIZATION_NOT_FOUND', 404);
}
return mergeAiTutorSettings(defaultAiTutorSettings, updated as Partial<AiTutorSettings>);
}
export interface CourseAiTutorSettingsView {
org: AiTutorSettings;
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null;
effective: AiTutorSettings;
}
export async function getCourseAiTutorSettingsView(
orgId: string,
courseId: string
): Promise<CourseAiTutorSettingsView> {
const [org, override, effective] = await Promise.all([
getResolvedOrgAiTutorSettings(orgId),
getCourseAiTutorOverride(courseId),
getEffectiveAiTutorSettings(orgId, courseId)
]);
return {
org,
override: (override as (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null) ?? null,
effective
};
}
export async function updateCourseAiTutorOverrideService(
courseId: string,
patch: Partial<AiTutorSettings> & { inheritFromOrg?: boolean }
): Promise<Partial<AiTutorSettings> & { inheritFromOrg?: boolean }> {
const updated = await updateCourseAiTutorOverride(courseId, patch);
if (!updated) {
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
}
return updated as Partial<AiTutorSettings> & { inheritFromOrg?: boolean };
}
+271
View File
@@ -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()
};
}
+7 -5
View File
@@ -32,13 +32,15 @@ const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
// gemini-2.5-flash kept for backward compat with historical usage records.
const MODEL_COST_MULTIPLIER: Record<string, number> = {
'gemini-2.5-flash': 1,
'gpt-5.4-mini': 3,
'gemini-3.1-flash-lite': 1,
'gemini-2.5-flash': 1.5,
'gpt-5.4-mini': 4,
'claude-sonnet-4-6': 11,
'claude-haiku-4-5-20251001': 1,
'kimi-k2.6': 3
'claude-haiku-4-5-20251001': 1.5,
'kimi-k2.6': 4
};
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
+2
View File
@@ -0,0 +1,2 @@
export * from './ingest';
export * from './reads';
+51
View File
@@ -0,0 +1,51 @@
import { insertPageEvents } from '@cio/db/queries/analytics';
import type { TIngestBatch, TTrackEvent } from '@cio/utils/validation/dash';
type EnrichmentContext = {
country: string | null;
userAgent: string | null;
userId: string | null;
};
function deriveDeviceType(userAgent: string | null): string | null {
if (!userAgent) return null;
const ua = userAgent.toLowerCase();
if (/tablet|ipad/.test(ua)) return 'tablet';
if (/mobi|android|iphone|ipod/.test(ua)) return 'mobile';
if (/bot|crawler|spider|crawling/.test(ua)) return 'bot';
return 'desktop';
}
function clampCountry(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim().toUpperCase();
if (trimmed.length !== 2) return null;
return trimmed;
}
export async function ingestEventBatch(batch: TIngestBatch, ctx: EnrichmentContext) {
const occurredAtFallback = new Date().toISOString();
const country = clampCountry(ctx.country);
const deviceType = deriveDeviceType(ctx.userAgent);
const rows = batch.events.map((event: TTrackEvent) => ({
occurredAt: event.occurredAt ?? occurredAtFallback,
sessionId: batch.sessionId,
eventType: event.eventType,
orgId: event.orgId ?? null,
userId: ctx.userId,
courseId: event.courseId ?? null,
path: event.path ?? null,
referrerHost: event.referrerHost ?? null,
utmSource: event.utmSource ?? null,
utmMedium: event.utmMedium ?? null,
utmCampaign: event.utmCampaign ?? null,
country,
deviceType,
locale: event.locale ?? null,
props: (event.props ?? {}) as Record<string, unknown>
}));
const inserted = await insertPageEvents(rows);
return { inserted };
}
+194
View File
@@ -0,0 +1,194 @@
import {
selectCountryBreakdown,
selectCourseDailyRange,
selectOrgDailyRange,
selectPopularCourseTypes
} from '@cio/db/queries/analytics';
import { DASH_ANALYTICS_TTL_SECONDS, dashAnalyticsKey } from '@api/utils/redis/key-generators';
import { env } from '@api/config/env';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export type LandingStats = {
totals: {
landingViews: number;
coursePageViews: number;
uniqueVisitors: number;
enrollments: number;
completions: number;
};
sparkline: Array<{ date: string; views: number; enrollments: number }>;
};
export type CountryBreakdown = Array<{ country: string; views: number; enrollments: number }>;
export type CourseFunnel = {
steps: Array<{
name: 'landing_view' | 'course_page_view' | 'enrollment_completed' | 'course_completed';
count: number;
conversionFromPrev: number | null;
}>;
};
export type PopularTypes = Array<{
type: string;
enrollments: number;
views: number;
completions: number;
courseCount: number;
}>;
function toDateString(date: Date): string {
return date.toISOString().slice(0, 10);
}
function computeRange(days: number): { fromDate: string; toDate: string } {
const today = new Date();
const from = new Date(today);
from.setUTCDate(from.getUTCDate() - (days - 1));
return { fromDate: toDateString(from), toDate: toDateString(today) };
}
async function readCache<T>(key: string): Promise<T | null> {
if (!env.REDIS_URL) return null;
try {
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch (error) {
logRedisUnavailableOnce('Redis get failed for analytics cache, using database', error);
return null;
}
}
async function writeCache(key: string, value: unknown): Promise<void> {
if (!env.REDIS_URL) return;
try {
await redis.setEx(key, DASH_ANALYTICS_TTL_SECONDS, JSON.stringify(value));
} catch (error) {
logRedisUnavailableOnce('Redis set failed for analytics cache, continuing', error);
}
}
export async function getLandingStats(orgId: string, days: number, bustCache = false): Promise<LandingStats> {
const key = dashAnalyticsKey('landing', orgId, days);
if (!bustCache) {
const cached = await readCache<LandingStats>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectOrgDailyRange(orgId, fromDate, toDate);
const totals = rows.reduce(
(acc, row) => ({
landingViews: acc.landingViews + row.landingViews,
coursePageViews: acc.coursePageViews + row.coursePageViews,
uniqueVisitors: acc.uniqueVisitors + row.uniqueVisitors,
enrollments: acc.enrollments + row.enrollments,
completions: acc.completions + row.completions
}),
{ landingViews: 0, coursePageViews: 0, uniqueVisitors: 0, enrollments: 0, completions: 0 }
);
const sparkline = rows.map((row) => ({
date: row.date,
views: row.landingViews + row.coursePageViews,
enrollments: row.enrollments
}));
const result: LandingStats = { totals, sparkline };
await writeCache(key, result);
return result;
}
export async function getCountryBreakdown(orgId: string, days: number, bustCache = false): Promise<CountryBreakdown> {
const key = dashAnalyticsKey('country', orgId, days);
if (!bustCache) {
const cached = await readCache<CountryBreakdown>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectCountryBreakdown(orgId, fromDate, toDate);
const result: CountryBreakdown = rows.map((row) => ({
country: row.country,
views: row.views,
enrollments: row.enrollments
}));
await writeCache(key, result);
return result;
}
export async function getCourseFunnel(
orgId: string,
days: number,
courseId: string | undefined,
bustCache = false
): Promise<CourseFunnel> {
const key = dashAnalyticsKey('funnel', orgId, days, courseId);
if (!bustCache) {
const cached = await readCache<CourseFunnel>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
let landingViews = 0;
let coursePageViews = 0;
let enrollments = 0;
let completions = 0;
if (courseId) {
const rows = await selectCourseDailyRange(courseId, orgId, fromDate, toDate);
coursePageViews = rows.reduce((sum, r) => sum + r.views, 0);
enrollments = rows.reduce((sum, r) => sum + r.enrollments, 0);
completions = rows.reduce((sum, r) => sum + r.completions, 0);
} else {
const rows = await selectOrgDailyRange(orgId, fromDate, toDate);
landingViews = rows.reduce((sum, r) => sum + r.landingViews, 0);
coursePageViews = rows.reduce((sum, r) => sum + r.coursePageViews, 0);
enrollments = rows.reduce((sum, r) => sum + r.enrollments, 0);
completions = rows.reduce((sum, r) => sum + r.completions, 0);
}
const stepValues = [
{ name: 'landing_view' as const, count: landingViews },
{ name: 'course_page_view' as const, count: coursePageViews },
{ name: 'enrollment_completed' as const, count: enrollments },
{ name: 'course_completed' as const, count: completions }
];
const steps = stepValues
.filter((step) => (courseId ? step.name !== 'landing_view' : true))
.map((step, index, arr) => {
const prev = index > 0 ? arr[index - 1].count : null;
const conversionFromPrev = prev && prev > 0 ? step.count / prev : null;
return { ...step, conversionFromPrev };
});
const result: CourseFunnel = { steps };
await writeCache(key, result);
return result;
}
export async function getPopularTypes(orgId: string, days: number, bustCache = false): Promise<PopularTypes> {
const key = dashAnalyticsKey('popular-types', orgId, days);
if (!bustCache) {
const cached = await readCache<PopularTypes>(key);
if (cached) return cached;
}
const { fromDate, toDate } = computeRange(days);
const rows = await selectPopularCourseTypes(orgId, fromDate, toDate);
const result: PopularTypes = rows
.filter((row) => row.type)
.map((row) => ({
type: row.type as string,
enrollments: row.enrollments,
views: row.views,
completions: row.completions,
courseCount: row.courseCount
}));
await writeCache(key, result);
return result;
}
+74 -1
View File
@@ -9,6 +9,7 @@ import type {
TAssetUpdate,
TYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import type { TTranscriptResponse } from '@cio/utils/validation/media';
import {
countAssetUsagesByAsset,
createAssetUsage,
@@ -22,6 +23,9 @@ import {
listAssetUsagesByAsset,
updateAsset
} from '@cio/db/queries/assets';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
@@ -210,7 +214,7 @@ export async function listOrganizationAssetsService(orgId: string, query: TAsset
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
try {
return await createOrGetAssetByStorageKey({
const asset = await createOrGetAssetByStorageKey({
organizationId: orgId,
kind: data.kind,
provider: data.provider,
@@ -230,6 +234,19 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
metadata: data.metadata ?? {},
createdByProfileId: profileId
});
// Fire-and-forget: enqueue lesson-video post-processing for new uploads.
// Errors are logged but never block the asset create response.
if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) {
void enqueueMediaPostProcessingForAsset({
organizationId: orgId,
assetId: asset.id,
storageKey: asset.storageKey,
triggeredByProfileId: profileId
});
}
return asset;
} catch (error) {
throw new AppError(
error instanceof Error ? error.message : 'Failed to create asset',
@@ -239,6 +256,26 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
}
}
async function enqueueMediaPostProcessingForAsset(input: {
organizationId: string;
assetId: string;
storageKey: string;
triggeredByProfileId: string;
}): Promise<void> {
try {
const { startMediaJob } = await import('@api/services/jobs');
await startMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
storageKey: input.storageKey,
triggeredByProfileId: input.triggeredByProfileId,
withTranscription: Boolean(process.env.OPENAI_API_KEY)
});
} catch (error) {
console.error('enqueueMediaPostProcessingForAsset failed:', error);
}
}
export async function getAssetService(orgId: string, assetId: string) {
try {
const asset = await getAssetById(assetId, orgId);
@@ -394,6 +431,42 @@ export async function getOrganizationAssetStorageService(orgId: string, query: T
}
}
export async function getTranscriptForOrganizationAssetService(
orgId: string,
assetId: string
): Promise<TTranscriptResponse | null> {
try {
const asset = assertAssetExists(await getAssetById(assetId));
if (asset.organizationId !== orgId) {
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
}
const row = await getMediaTranscriptByAsset(assetId, orgId);
if (!row) {
return null;
}
const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket);
const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString();
return {
language: row.language,
segments: row.segments,
vttUrl,
vttUrlExpiresAt,
durationSeconds: row.durationSeconds ?? null
};
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to fetch transcript',
ErrorCodes.ASSET_FETCH_FAILED,
500
);
}
}
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
const videoId = extractYouTubeVideoId(query.url);
if (!videoId) {
@@ -0,0 +1,73 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseById, getOrganizationById, getProfileById, getCourseOrganizationId } from '@cio/db/queries';
import { resolveCertificateDesign, type CertificateRenderInput } from '@api/utils/certificate';
import type { TCertificateDownloadRequest } from '@cio/utils/validation/course';
/**
* Loads the design + render data for a given course/student pair so the API
* can hand it to `generateCertificatePdf` / `generateCertificatePng`.
*
* The client only sends `studentName` (+ optional studentId/issuedAt). Everything
* else comes from the database: title, description, org name + logo, design.
*/
export async function assembleCertificateRender(
courseId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const [courseRow] = await getCourseById(courseId);
if (!courseRow) {
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
}
const organizationId = await getCourseOrganizationId(courseId);
const organization = organizationId ? await getOrganizationById(organizationId) : null;
const design = resolveCertificateDesign(courseRow.certificate);
const issuedAtIso = body.issuedAt ?? new Date().toISOString();
const issuedAtDate = new Date(issuedAtIso);
const date = Number.isNaN(issuedAtDate.getTime())
? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' })
: issuedAtDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' });
const certificateId = body.studentId
? formatCertificateId(design.idFormat, body.studentId, issuedAtDate)
: formatCertificateId(design.idFormat, fallbackSequence(issuedAtDate), issuedAtDate);
return {
design,
data: {
recipientName: body.studentName,
courseName: courseRow.title,
courseDescription: courseRow.description ?? '',
orgName: organization?.name ?? '',
orgLogoUrl: organization?.avatarUrl ?? undefined,
date,
certificateId
}
};
}
function formatCertificateId(format: string | undefined, seq: string, issuedAt: Date): string {
const year = issuedAt.getFullYear();
const month = String(issuedAt.getMonth() + 1).padStart(2, '0');
const tail = seq.replace(/-/g, '').slice(-4).toUpperCase() || '0001';
return (format ?? 'N° {seq}').replace('{seq}', tail).replace('{year}', String(year)).replace('{month}', month);
}
function fallbackSequence(issuedAt: Date): string {
return String(issuedAt.getTime()).slice(-6);
}
export async function assembleOwnerPreviewRender(
courseId: string,
userId: string,
body: TCertificateDownloadRequest
): Promise<CertificateRenderInput> {
const studentName = body.studentName.trim().length
? body.studentName
: (await getProfileById(userId))?.fullname || 'Preview Recipient';
return assembleCertificateRender(courseId, { ...body, studentName });
}
@@ -16,6 +16,7 @@ import {
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = {
code:
@@ -240,6 +241,22 @@ export async function evaluateCourseCertification(
courseRow,
earnedAt
});
trackServerEvent({
eventType: SERVER_EVENTS.COURSE_COMPLETED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
if (courseRow.certificate?.isDownloadable) {
trackServerEvent({
eventType: SERVER_EVENTS.CERTIFICATE_ISSUED,
userId: profileId,
courseId,
props: { path: 'standard', earnedAt }
});
}
}
return { ...evaluation, certificateEarnedAt, isNewCompletion };
+108 -1
View File
@@ -6,11 +6,13 @@ import {
getCourseComplianceHistoryRows,
getCourseCurrentComplianceRows,
getLatestComplianceRecordsByProfiles,
getOrgComplianceLearnerRows,
getStudentCourseMembersForCompliance,
listComplianceRecordsReadyForExpiry,
listLatestComplianceRecordsForReminderScan,
updateCourseCertificateIssueStatusByRecordId,
updateCourseCompletionRecord
updateCourseCompletionRecord,
type OrgComplianceLearnerRow
} from '@cio/db/queries/course/compliance';
import { db } from '@cio/db/drizzle';
import { getCourseById } from '@cio/db/queries/course';
@@ -712,3 +714,108 @@ export async function runComplianceReminderScan() {
reminderEventCount
};
}
type OrgComplianceStatus = ComplianceStatus | 'no_record';
const STATUS_KEYS: OrgComplianceStatus[] = [
'compliant',
'expiring_soon',
'in_grace_period',
'non_compliant',
'waived',
'in_progress',
'not_started',
'no_record'
];
function emptyStatusCounts() {
const counts = {} as Record<OrgComplianceStatus, number>;
for (const status of STATUS_KEYS) counts[status] = 0;
return counts;
}
export type OrgComplianceOverview = {
summary: {
totalLearners: number;
totalCourses: number;
counts: Record<OrgComplianceStatus, number>;
};
courses: Array<{
courseId: string;
courseTitle: string;
learnerCount: number;
counts: Record<OrgComplianceStatus, number>;
}>;
learners: Array<{
groupMemberId: string;
profileId: string | null;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
courseId: string;
courseTitle: string;
status: OrgComplianceStatus;
cycleNumber: number | null;
dueDate: string | null;
completedAt: string | null;
validUntil: string | null;
}>;
};
/**
* Aggregates compliance state across every COMPLIANCE course in an
* organization. Returns status tallies, per-course breakdown, and the
* flat learner-by-course list for the dashboard table.
*/
export async function getOrgComplianceOverview(orgId: string): Promise<OrgComplianceOverview> {
const rows = await getOrgComplianceLearnerRows(orgId);
const summaryCounts = emptyStatusCounts();
const courseMap = new Map<
string,
{ title: string; learnerCount: number; counts: Record<OrgComplianceStatus, number> }
>();
const uniqueLearners = new Set<string>();
for (const row of rows) {
const status = row.status as OrgComplianceStatus;
summaryCounts[status] = (summaryCounts[status] ?? 0) + 1;
if (row.profileId) uniqueLearners.add(row.profileId);
let courseAcc = courseMap.get(row.courseId);
if (!courseAcc) {
courseAcc = { title: row.courseTitle, learnerCount: 0, counts: emptyStatusCounts() };
courseMap.set(row.courseId, courseAcc);
}
courseAcc.learnerCount += 1;
courseAcc.counts[status] = (courseAcc.counts[status] ?? 0) + 1;
}
return {
summary: {
totalLearners: uniqueLearners.size,
totalCourses: courseMap.size,
counts: summaryCounts
},
courses: Array.from(courseMap.entries()).map(([courseId, c]) => ({
courseId,
courseTitle: c.title,
learnerCount: c.learnerCount,
counts: c.counts
})),
learners: rows.map((row: OrgComplianceLearnerRow) => ({
groupMemberId: row.groupMemberId,
profileId: row.profileId,
fullname: row.profile?.fullname ?? null,
email: row.email,
avatarUrl: row.profile?.avatarUrl ?? null,
courseId: row.courseId,
courseTitle: row.courseTitle,
status: row.status as OrgComplianceStatus,
cycleNumber: row.cycleNumber,
dueDate: row.dueDate,
completedAt: row.completedAt,
validUntil: row.validUntil
}))
};
}
+36 -40
View File
@@ -29,11 +29,13 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: {
}) {
try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
orgName: input.orgName,
courseName: input.courseName,
loginUrl
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send student welcome email:', error);
console.error('Failed to enqueue student welcome email:', error);
}
try {
@@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: {
const studentProfile = await getProfileById(input.studentId);
const studentName = studentProfile?.fullname || input.studentEmail;
await Promise.all(
teachers
.map((teacher) => teacher.email)
.filter((email): email is string => !!email)
.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email);
if (teacherEmails.length === 0) return;
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName: input.courseName,
studentName,
studentEmail: input.studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((error) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error);
})
)
);
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
});
} catch (error) {
console.error('Failed to send teacher join notification emails:', error);
console.error('Failed to enqueue teacher join notification emails:', error);
}
}
@@ -434,7 +433,7 @@ async function createEmailInviteAndSend(input: {
}
try {
const responses = await sendEmail('studentCourseInvite', {
await enqueueTransactionalEmail('studentCourseInvite', {
to: input.recipientEmail,
fields: {
orgName: input.orgName,
@@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: {
inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt)
},
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-invite-email:${createdInvite.id}`
});
const allSuccessful = responses.every((response) => response.success);
if (!allSuccessful) {
const errorMessage = responses
.filter((response) => !response.success)
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_FAILED', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail,
metadata: { error: errorMessage }
});
return {
invite: createdInvite,
sent: false,
error: errorMessage
};
}
// Optimistic EMAIL_SENT — see comment in services/organization/invite.ts.
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail
@@ -683,6 +664,14 @@ export async function enrollInCourse(
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
orgId: org.id,
userId: user.id,
courseId,
props: { path: 'free-enrollment' }
});
return {
success: true,
alreadyJoined: false,
@@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
studentId: user.id,
studentEmail: normalizedEmail
});
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
userId: user.id,
courseId: result.courseId,
props: { path: 'invite' }
});
}
return {
+17 -11
View File
@@ -2,7 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export interface PaymentRequestData {
courseId: string;
@@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email;
// Send email to teacher about student buy request
try {
await sendEmail('teacherStudentBuyRequest', {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
fields: {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname
},
from: buildEmailFromName('ClassroomIO')
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send teacher buy request email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue teacher buy request email:', emailError);
}
// Send email to student with payment instructions
try {
await sendEmail('studentProvePayment', {
await enqueueTransactionalEmail('studentProvePayment', {
to: data.studentEmail,
fields: {
courseName,
@@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) {
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail
replyTo: teacherEmail,
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student payment proof email:', emailError);
// Don't fail the request if email fails
console.error('Failed to enqueue student payment proof email:', emailError);
}
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
courseId: data.courseId,
props: { path: 'payment-request', orgName }
});
return {
success: true,
courseName,
+22 -24
View File
@@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
/**
@@ -85,45 +86,44 @@ export async function addMember(
studentName = data.name || null;
}
// Send welcome email to student
if (studentEmail) {
try {
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: studentEmail,
fields: {
orgName,
courseName,
loginUrl
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to send student welcome email:', emailError);
console.error('Failed to enqueue student welcome email:', emailError);
}
}
// Get all teachers (ADMIN or TUTOR) in the course
const teachersResult = await getCourseTeachers({ courseId });
// Send notification to all teachers
if (teachersResult.length > 0 && studentEmail && studentName) {
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
await Promise.all(
teacherEmails.map((teacherEmail) =>
sendEmail('teacherStudentJoined', {
to: teacherEmail,
if (teacherEmails.length > 0) {
try {
await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmails,
fields: {
courseName,
studentName,
studentEmail
},
from: buildEmailFromName('ClassroomIO')
}).catch((emailError) => {
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError);
})
)
);
from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
});
} catch (emailError) {
console.error('Failed to enqueue teacher notification email:', emailError);
}
}
}
}
} catch (emailError) {
@@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const baseUrl = getDashboardBaseUrl(orgSiteName);
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
// Send welcome emails to teachers
const teacherEmailPromises: Promise<unknown>[] = [];
addedMembers.forEach((member, index) => {
const memberData = members[index];
// Only send emails to teachers (TUTOR or ADMIN role) who have email
const roleId = memberData.roleId;
const email = memberData.email;
const name = memberData.name;
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
teacherEmailPromises.push(
sendEmail('teacherCourseWelcome', {
enqueueTransactionalEmail('teacherCourseWelcome', {
to: email,
fields: {
name,
@@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
courseName,
inviteLink
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
}).catch((emailError) => {
console.error(`Failed to send welcome email to ${email}:`, emailError);
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
return [];
})
);
@@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
try {
await Promise.all(teacherEmailPromises);
} catch (emailError) {
// Log but don't fail the request if email fails
console.error('Failed to send welcome emails:', emailError);
console.error('Failed to enqueue welcome emails:', emailError);
}
}
+72 -3
View File
@@ -1,4 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
} from '@api/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
@@ -8,7 +16,6 @@ import {
} from '@cio/db/queries/course';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
/**
* Anonymous-safe: fetch the full tree for a published public course by slug.
@@ -24,7 +31,7 @@ export async function getPublicCourseTreeService(courseSlug: string): Promise<Pu
}
export type PublicItemResponse =
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
| (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
kind: 'exercise';
questions: PublicExercisePayload;
@@ -74,7 +81,69 @@ export async function getPublicCourseItemService(
if (!baseItem) return null;
if (baseItem.kind === 'lesson') {
return baseItem;
const { courseOrganizationId, ...lesson } = baseItem;
let video = lesson.video;
if (video?.type === 'upload') {
let storageKey: string | undefined;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
if (signedLink) {
const { key: _omitKey, ...rest } = video;
video = { ...rest, link: signedLink };
}
}
}
if (video?.assetId && courseOrganizationId) {
const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId);
let transcript: {
vttUrl: string;
vttUrlExpiresAt: string;
language: string;
} | null = null;
if (transcriptRow) {
const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket);
transcript = {
vttUrl,
vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(),
language: transcriptRow.language
};
}
video = { ...video, transcript };
}
if (video?.key !== undefined) {
const { key: _omitS3Key, ...withoutKey } = video;
video = withoutKey;
}
return { ...lesson, video };
}
if (!baseItem.isUnlocked) {
return {
...baseItem,
questions: {
unsectioned: [],
sections: []
}
};
}
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
+126
View File
@@ -0,0 +1,126 @@
import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
import * as z from 'zod';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
type Recipient = string | string[];
interface CommonOptions {
/**
* Stable key used as BullMQ `jobId` for idempotent enqueue (e.g.
* `welcome:<userId>`). When omitted the job gets an auto-generated id, so
* duplicate calls produce duplicate emails.
*/
idempotencyKey?: string;
}
export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOptions {
to: Recipient;
fields: z.infer<EmailSchemaFor<TId>>;
from?: string;
replyTo?: string;
}
export interface EnqueueRawEmailInput extends CommonOptions {
to: Recipient;
subject: string;
content: string;
from?: string;
replyTo?: string;
}
export interface EnqueueResult {
/** BullMQ job ids — one per recipient. */
jobIds: string[];
}
function toRecipientArray(to: Recipient): string[] {
return Array.isArray(to) ? to : [to];
}
function recipientKey(base: string | undefined, recipient: string, total: number): string | undefined {
if (!base) return undefined;
return total === 1 ? base : `${base}:${recipient}`;
}
/**
* Fire-and-forget enqueue of a registered template email. Validates `fields`
* against the template's Zod schema up front so bad payloads fail in the
* domain handler instead of silently inside the worker.
*
* BullMQ tracks send state, retries, and failure history — no DB ledger is
* written. Final failures are recorded in `dead_letter_job` by the worker
* for operator triage.
*/
export async function enqueueTransactionalEmail<TId extends EmailId>(
template: TId,
input: EnqueueTemplateEmailInput<TId>
): Promise<EnqueueResult> {
const definition = EmailRegistry.get(template);
if (!definition) {
throw new Error(`Email template "${template}" is not registered`);
}
const validatedFields = definition.schema.parse(input.fields) as Record<string, unknown>;
if (!isRedisConfigured()) {
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
return { jobIds: [] };
}
const recipients = toRecipientArray(input.to);
const jobIds: string[] = [];
for (const recipient of recipients) {
const jobId = await enqueueEmailSend(
{
kind: 'template',
template,
to: recipient,
fields: validatedFields,
from: input.from,
replyTo: input.replyTo
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
);
if (jobId) jobIds.push(jobId);
}
return { jobIds };
}
/**
* Fire-and-forget enqueue of a free-form subject/content email — used by the
* public mail route and submission notifications that don't have a registered
* template.
*/
export async function enqueueRawEmail(input: EnqueueRawEmailInput): Promise<EnqueueResult> {
if (!isRedisConfigured()) {
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
return { jobIds: [] };
}
const recipients = toRecipientArray(input.to);
const jobIds: string[] = [];
for (const recipient of recipients) {
const jobId = await enqueueEmailSend(
{
kind: 'raw',
to: recipient,
subject: input.subject,
content: input.content,
from: input.from,
replyTo: input.replyTo
},
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
);
if (jobId) jobIds.push(jobId);
}
return { jobIds };
}
+2
View File
@@ -0,0 +1,2 @@
export * from './media-jobs';
export * from './email-jobs';
+248
View File
@@ -0,0 +1,248 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '@cio/jobs/status';
import {
createMediaJob,
getMediaJobById,
listJobStepsByRun,
listLatestMediaJobsByAsset,
listMediaJobsByIds,
requestMediaJobCancel,
updateMediaJob,
hasActiveMediaJobForAsset
} from '@cio/db/queries';
import { getAssetById } from '@cio/db/queries/assets';
import { enqueueLessonVideoPipeline, enqueueTranscriptionOnly, isRedisConfigured } from '@cio/jobs';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
import type { TMediaJob } from '@db/types';
export interface StartTranscriptionOnlyMediaJobInput {
organizationId: string;
assetId: string;
triggeredByProfileId: string | null;
}
/**
* Manual transcription: new `media_job` + extract-audio → transcribe-audio only.
* Requires OpenAI key, upload video asset, and no other active media_job for the asset.
*/
export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOnlyMediaJobInput): Promise<TMediaJob> {
if (!process.env.OPENAI_API_KEY) {
throw new AppError('Transcription not configured', ErrorCodes.OPENAI_KEY_MISSING, 503);
}
const asset = await getAssetById(input.assetId);
if (!asset || asset.organizationId !== input.organizationId) {
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
}
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
if (hasActive) {
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
}
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'transcribing',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: transcription media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueTranscriptionOnly({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: asset.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
}
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startTranscriptionOnlyMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue transcription job'
}
});
throw new AppError(
error instanceof Error ? error : 'Failed to enqueue transcription job',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export interface StartMediaJobInput {
organizationId: string;
assetId: string;
storageKey: string;
triggeredByProfileId: string | null;
/** Set to false for assets without an OpenAI key (skip transcribe leg). */
withTranscription?: boolean;
}
/**
* Create a `media_job` row and enqueue the BullMQ flow. Falls back to a
* persisted `queued` row with no BullMQ job id when Redis is unavailable so
* the API returns a stable status envelope and a follow-up `start` worker
* can pick it up later. The enqueue itself fails loudly (the DB row stays
* around so an operator can replay it).
*/
export async function startMediaJob(input: StartMediaJobInput): Promise<TMediaJob> {
const job = await createMediaJob({
organizationId: input.organizationId,
assetId: input.assetId,
triggeredByProfileId: input.triggeredByProfileId,
status: 'queued',
stage: 'queued',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: media job created but no BullMQ flow enqueued. ' +
'Set REDIS_URL and run apps/jobs to process this job.'
);
return job;
}
try {
const enqueueResult = await enqueueLessonVideoPipeline({
mediaJobId: job.id,
assetId: input.assetId,
storageKey: input.storageKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
},
withTranscription: input.withTranscription
});
const updated = await updateMediaJob(job.id, {
rootJobId: enqueueResult.rootJobId,
jobIds: enqueueResult.jobIds
});
return updated ?? job;
} catch (error) {
console.error('startMediaJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue media job'
}
});
throw new AppError(error instanceof Error ? error : 'Failed to enqueue media job', ErrorCodes.INTERNAL_ERROR, 500);
}
}
export interface GetMediaJobEnvelopeOptions {
pollCount?: number;
}
function toEnvelope(job: TMediaJob, options: GetMediaJobEnvelopeOptions = {}): JobEnvelope {
const status = job.status as JobStatus;
const pollCount = options.pollCount ?? 0;
return {
job: {
id: job.id,
domain: 'media',
status,
stage: job.stage,
progressPercent: job.progressPercent,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
result: job.result ?? null,
error: job.error ?? null,
warnings: job.warnings ?? [],
cancelRequestedAt: job.cancelRequestedAt,
attempt: job.attempt,
maxAttempts: job.maxAttempts
},
events: [],
nextPollMs: suggestNextPollMs(status, pollCount)
};
}
export async function getMediaJobEnvelope(
jobId: string,
orgId: string,
options: GetMediaJobEnvelopeOptions = {}
): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return toEnvelope(job, options);
}
export async function listMediaJobEnvelopes(jobIds: string[], orgId: string): Promise<Record<string, JobEnvelope>> {
if (jobIds.length === 0) return {};
const rows = await listMediaJobsByIds(jobIds, orgId);
const map: Record<string, JobEnvelope> = {};
for (const row of rows) {
map[row.id] = toEnvelope(row);
}
return map;
}
export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise<JobEnvelope[]> {
const rows = await listLatestMediaJobsByAsset(assetId, orgId);
return rows.map((row) => toEnvelope(row));
}
export async function cancelMediaJob(jobId: string, orgId: string): Promise<JobEnvelope> {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
if (job.status === JOB_STATUS.completed || job.status === JOB_STATUS.failed || job.status === JOB_STATUS.canceled) {
return toEnvelope(job);
}
const updated = await requestMediaJobCancel(jobId, orgId);
return toEnvelope(updated ?? job);
}
export async function getMediaJobSteps(jobId: string, orgId: string) {
const job = await getMediaJobById(jobId, orgId);
if (!job) {
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
}
return listJobStepsByRun('media', jobId);
}
+12 -11
View File
@@ -18,7 +18,8 @@ import {
} from '@cio/db/queries/newsfeed';
import { env } from '@api/config/env';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
/**
* Lists newsfeed items for a course
@@ -359,12 +360,14 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
// Send email to all course members
const emailPromises = feedData.courseMembers.map((member) => {
if (!member.email) return Promise.resolve();
const recipients = feedData.courseMembers
.map((member) => member.email)
.filter((email): email is string => Boolean(email));
return sendEmail('newsfeedPost', {
to: member.email,
if (recipients.length === 0) return;
await enqueueTransactionalEmail('newsfeedPost', {
to: recipients,
fields: {
courseTitle: feedData.courseTitle!,
teacherName: feedData.author?.fullname || 'A teacher',
@@ -373,11 +376,9 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
orgName
},
from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || 'noreply@classroomio.com'
replyTo: feedData.author?.email || 'noreply@classroomio.com',
idempotencyKey: `newsfeed:post:${feedId}`
});
});
await Promise.all(emailPromises);
} catch (error) {
console.error('Error sending newsfeed post email:', error);
// Don't throw - email failures shouldn't break the API
@@ -403,7 +404,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await sendEmail('newsfeedComment', {
await enqueueTransactionalEmail('newsfeedComment', {
to: feedData.author.email,
fields: {
courseTitle: feedData.courseTitle,
+4 -3
View File
@@ -14,7 +14,7 @@ import { env } from '@api/config/env';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
import { db } from '@cio/db/drizzle';
import { sendEmail } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
export async function createOrganizationWithOwner(
profileId: string,
@@ -125,11 +125,12 @@ export async function completeOnboarding(user: Partial<TUser> & { id: string; em
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
}
await sendEmail('welcome', {
await enqueueTransactionalEmail('welcome', {
to: user.email,
fields: {
name: profile.fullname
}
},
idempotencyKey: `onboarding:welcome:${user.id}`
});
} catch (error) {
if (error instanceof AppError) {
+7 -1
View File
@@ -32,6 +32,7 @@ import {
getPublishedCoursesBySiteName
} from '@cio/db/queries/course';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
import { getAccountPrimary } from '@cio/db/queries/account';
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
@@ -524,8 +525,13 @@ export async function getOrgSetupData(siteName: string) {
*/
export async function createOrgPlan(data: TNewOrganizationPlan) {
try {
// Subscriptions always attach to the primary workspace, even if a
// secondary org id was supplied at checkout.
const primary = data.orgId ? await getAccountPrimary(data.orgId) : null;
const targetOrgId = primary?.id ?? data.orgId;
const plan = await createOrganizationPlan({
orgId: data.orgId,
orgId: targetOrgId,
planName: data.planName,
subscriptionId: data.subscriptionId,
triggeredBy: data.triggeredBy,
+23 -34
View File
@@ -5,7 +5,8 @@ import type {
TImportAudienceMembers
} from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import {
createOrganizationInvite,
@@ -202,18 +203,19 @@ async function enrollAudienceStudentProfilesInCourses(
.map(async (p) => {
const email = profileEmailMap.get(p.profileId)!;
try {
await sendEmail('studentCourseWelcome', {
await enqueueTransactionalEmail('studentCourseWelcome', {
to: email,
fields: {
orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
console.error(`enrollAudienceStudentProfilesInCourses enqueue error for ${email}:`, emailError);
}
});
@@ -283,18 +285,19 @@ async function enrollAudienceStudentProfilesInPrograms(
const email = profileEmailMap.get(pair.profileId)!;
try {
await sendEmail('studentProgramWelcome', {
await enqueueTransactionalEmail('studentProgramWelcome', {
to: email,
fields: {
orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program',
loginUrl
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
});
emailsSent++;
} catch (emailError) {
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError);
}
})
);
@@ -385,7 +388,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
const token = tokenByEmail.get(email)!;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
await enqueueTransactionalEmail('studentOrgInvite', {
to: email,
fields: {
email,
@@ -394,27 +397,17 @@ async function createStudentOrgInvitesAndSendEmails(input: {
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`
});
const allSuccessful = responses.every((r) => r.success);
if (allSuccessful) {
// Optimistic — see comment in services/organization/invite.ts.
return {
inviteId: invite.id,
email,
success: true as const,
error: undefined as string | undefined
};
}
return {
inviteId: invite.id,
email,
success: false as const,
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
};
} catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
return { inviteId: invite.id, email, success: false as const, error: message };
@@ -685,7 +678,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
let emailSent = false;
try {
const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', {
await enqueueTransactionalEmail('studentOrgInvite', {
to: emailToUse,
fields: {
email: emailToUse,
@@ -694,27 +687,23 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel
},
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}`
});
const allSuccessful = responses.every((r) => r.success);
emailSent = allSuccessful;
emailSent = true;
// Optimistic EMAIL_SENT — worker handles retries; final failure flips
// email_delivery to `failed` for operator follow-up.
await createOrganizationInviteAudits([
{
inviteId: invite.id,
organizationId: orgId,
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
eventType: 'EMAIL_SENT',
actorProfileId: invitedByProfileId,
targetEmail: emailToUse,
ipAddress: null,
userAgent: null,
metadata: allSuccessful
? {}
: {
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
}
metadata: {}
}
]);
} catch (emailError) {
+8 -19
View File
@@ -27,7 +27,7 @@ import { db, type DbOrTxClient } from '@cio/db/drizzle';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -244,7 +244,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
const inviteLink = buildInviteLink(token);
try {
const responses = await sendEmail('inviteTeacher', {
await enqueueTransactionalEmail('inviteTeacher', {
to: email,
fields: {
email,
@@ -253,25 +253,14 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
roleName,
expiresAt: getExpiryLabel(expiresAt),
inviteLink
}
},
idempotencyKey: `org-invite-teacher:${invite.id}`
});
const allSuccessful = responses.every((response) => response.success);
if (!allSuccessful) {
const message = responses
.filter((response) => !response.success)
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', {
actorProfileId: invitedByProfileId,
targetEmail: email,
metadata: { error: message }
});
continue;
}
// Record EMAIL_SENT optimistically — the worker retries up to 5 times,
// and a final failure flips the email_delivery row to `failed` for
// operator follow-up. EMAIL_FAILED here only means the enqueue itself
// failed (DB outbox write or transient validation error).
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
actorProfileId: invitedByProfileId,
targetEmail: email
+485
View File
@@ -0,0 +1,485 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
countAssignmentsByStatus,
createProgramGoal,
deleteProgramGoal,
getAssignmentsForProfile,
getLatestCompletionRecordsForProfilesAndCourses,
getMaxScoresForProfilesAndCourses,
getNonComplianceCourseCompletions,
getProgramById,
getProgramGoalAssignments,
getProgramGoalById,
getProgramGoals,
getProgramGoalsByOrg,
getProgramMembers,
listAllActiveProgramGoals,
listAssignmentsForReminderScan,
type TNewProgramGoalAssignment,
type TProgramGoal,
type TProgramGoalAssignment,
updateProgramGoal as updateProgramGoalQuery,
upsertProgramGoalAssignments
} from '@cio/db/queries/program';
import { ROLE } from '@cio/utils/constants';
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
const AT_RISK_DAYS = 3;
const DAY_MS = 24 * 60 * 60 * 1000;
type GoalType = TProgramGoal['type'];
// ─── CRUD ────────────────────────────────────────────────────────────────────
export async function createGoal(
programId: string,
profileId: string,
data: TCreateProgramGoal
): Promise<TProgramGoal> {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
try {
const goal = await createProgramGoal({
programId,
title: data.title,
description: data.description ?? null,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
reminderDaysBefore: data.reminderDaysBefore,
createdByProfileId: profileId
});
await evaluateGoal(goal.id).catch((error) => {
console.error('createGoal: initial evaluation failed', error);
});
return goal;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to create goal',
ErrorCodes.PROGRAM_GOAL_CREATE_FAILED,
500
);
}
}
export async function listGoals(
programId: string
): Promise<Array<TProgramGoal & { statusCounts: Record<string, number> }>> {
const goals = await getProgramGoals(programId);
if (goals.length === 0) return [];
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return goals.map((goal) => ({
...goal,
statusCounts: counts.get(goal.id) ?? {}
}));
}
export async function getGoal(goalId: string): Promise<TProgramGoal> {
const goal = await getProgramGoalById(goalId);
if (!goal) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return goal;
}
export async function updateGoal(goalId: string, data: TUpdateProgramGoal): Promise<TProgramGoal> {
const existing = await getProgramGoalById(goalId);
if (!existing) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
try {
const updated = await updateProgramGoalQuery(goalId, {
title: data.title,
description: data.description ?? undefined,
type: data.type,
courseIds: data.courseIds,
requiredCount: data.requiredCount ?? null,
scoreThreshold: data.scoreThreshold ?? null,
teamPassRateThreshold: data.teamPassRateThreshold ?? null,
reminderDaysBefore: data.reminderDaysBefore,
deadlineKind: data.deadlineKind,
deadlineDate: data.deadlineDate ?? null,
relativeDays: data.relativeDays ?? null,
recurringMonths: data.recurringMonths ?? null,
status: data.status
});
if (!updated) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
await evaluateGoal(goalId).catch((error) => {
console.error('updateGoal: re-evaluation failed', error);
});
return updated;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
error instanceof Error ? error.message : 'Failed to update goal',
ErrorCodes.PROGRAM_GOAL_UPDATE_FAILED,
500
);
}
}
export async function archiveGoal(goalId: string): Promise<TProgramGoal> {
return updateGoal(goalId, { status: 'archived' });
}
export async function removeGoal(goalId: string): Promise<TProgramGoal> {
const deleted = await deleteProgramGoal(goalId);
if (!deleted) {
throw new AppError('Goal not found', ErrorCodes.PROGRAM_GOAL_NOT_FOUND, 404);
}
return deleted;
}
// ─── Evaluator ───────────────────────────────────────────────────────────────
/**
* Compute due date for one learner against a goal.
*
* - none / readiness → null
* - absolute → goal.deadlineDate
* - relative_to_join → joinedAt + relativeDays
* - recurring → goal.deadlineDate (current cycle); cycle advance handled elsewhere
*/
function computeDueDate(goal: TProgramGoal, joinedAt: Date): Date | null {
if (goal.deadlineKind === 'none') return null;
if (goal.deadlineKind === 'absolute') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
if (goal.deadlineKind === 'relative_to_join') {
if (!goal.relativeDays) return null;
return new Date(joinedAt.getTime() + goal.relativeDays * DAY_MS);
}
if (goal.deadlineKind === 'recurring') {
return goal.deadlineDate ? new Date(goal.deadlineDate) : null;
}
return null;
}
/**
* Decide a per-learner status from the cached counts and dates.
*
* Stays completed once it reaches `completed` (cron resets handle recurring).
*/
function decideStatus(args: {
completed: number;
required: number;
dueDate: Date | null;
now: Date;
previousStatus: TProgramGoalAssignment['status'] | null;
}): { status: TProgramGoalAssignment['status']; completedAt: Date | null } {
const { completed, required, dueDate, now, previousStatus } = args;
if (previousStatus === 'waived') {
return { status: 'waived', completedAt: null };
}
if (required > 0 && completed >= required) {
return { status: 'completed', completedAt: now };
}
if (dueDate && now.getTime() > dueDate.getTime()) {
return { status: 'overdue', completedAt: null };
}
if (dueDate && dueDate.getTime() - now.getTime() < AT_RISK_DAYS * DAY_MS) {
return { status: 'at_risk', completedAt: null };
}
if (completed > 0) {
return { status: 'in_progress', completedAt: null };
}
return { status: 'not_started', completedAt: null };
}
/**
* Re-evaluate every assignment for one goal.
*
* Pulls all student program-members, computes a per-learner status, and upserts
* `program_goal_assignment`. Designed to be safe to run repeatedly (idempotent)
* and cheap enough for daily cron over hundreds of programs.
*/
export async function evaluateGoal(goalId: string): Promise<{ evaluated: number }> {
const goal = await getProgramGoalById(goalId);
if (!goal) return { evaluated: 0 };
const members = await getProgramMembers(goal.programId);
const studentMembers = members.filter((member) => member.roleId === ROLE.STUDENT && member.profileId);
if (studentMembers.length === 0) return { evaluated: 0 };
const profileIds = studentMembers.map((member) => member.profileId!);
const courseIds = goal.courseIds ?? [];
const [completionRecords, nonComplianceCompletions, scoreMap, existingAssignments] = await Promise.all([
getLatestCompletionRecordsForProfilesAndCourses(profileIds, courseIds),
getNonComplianceCourseCompletions(profileIds, courseIds),
goal.type === 'score' || goal.type === 'pass_rate'
? getMaxScoresForProfilesAndCourses(profileIds, courseIds)
: Promise.resolve(new Map<string, number>()),
getProgramGoalAssignments(goalId)
]);
const completionByKey = new Map<string, { status: string; score: number | null }>();
for (const record of completionRecords) {
completionByKey.set(`${record.profileId}:${record.courseId}`, {
status: record.status,
score: record.score
});
}
const previousByMember = new Map(existingAssignments.map((assignment) => [assignment.programMemberId, assignment]));
const now = new Date();
const requiredFor = (type: GoalType, total: number) =>
type === 'n_of_m' && goal.requiredCount ? Math.min(goal.requiredCount, total) : total;
const rows: TNewProgramGoalAssignment[] = [];
for (const member of studentMembers) {
if (!member.profileId) continue;
const joinedAt = member.createdAt ? new Date(member.createdAt) : now;
const dueDate = computeDueDate(goal, joinedAt);
const completedCourseIds = courseIds.filter((courseId) => {
const key = `${member.profileId}:${courseId}`;
const record = completionByKey.get(key);
if (record) {
if (goal.type === 'score' || goal.type === 'pass_rate') {
const score = scoreMap.get(key);
return typeof score === 'number' && score >= (goal.scoreThreshold ?? 100);
}
if (record.status === 'compliant') return true;
}
// Fallback: non-compliance course where every lesson is completed.
if (goal.type !== 'score' && goal.type !== 'pass_rate') {
return nonComplianceCompletions.has(key);
}
return false;
});
const completed = completedCourseIds.length;
const required = requiredFor(goal.type, courseIds.length);
const previous = previousByMember.get(member.id);
const decision = decideStatus({
completed,
required,
dueDate,
now,
previousStatus: previous?.status ?? null
});
rows.push({
goalId,
programMemberId: member.id,
dueDate: dueDate ? dueDate.toISOString() : null,
status: decision.status,
completedCount: completed,
requiredCount: required,
completedAt:
decision.status === 'completed' ? (previous?.completedAt ?? decision.completedAt?.toISOString() ?? null) : null,
lastEvaluatedAt: now.toISOString()
});
}
await upsertProgramGoalAssignments(rows);
return { evaluated: rows.length };
}
/**
* Re-evaluate every active goal in a program. Used after material program
* changes (member added, course added) or by cron sweeps.
*/
export async function evaluateProgramGoals(programId: string): Promise<{ evaluated: number }> {
const goals = await getProgramGoals(programId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated };
}
/**
* Re-evaluate all goals in an organization. Used by the daily cron.
*/
export async function evaluateOrgGoals(organizationId: string): Promise<{ evaluated: number; goals: number }> {
const goals = await getProgramGoalsByOrg(organizationId);
let evaluated = 0;
for (const goal of goals) {
const result = await evaluateGoal(goal.id);
evaluated += result.evaluated;
}
return { evaluated, goals: goals.length };
}
/**
* Re-evaluate every active goal across every org. Designed for the daily cron
* sweep — same shape as `runComplianceExpiryCheck` for the compliance feature.
*/
export async function runProgramGoalEvaluationSweep(): Promise<{
goalsEvaluated: number;
assignmentsEvaluated: number;
}> {
const goals = await listAllActiveProgramGoals();
let assignmentsEvaluated = 0;
for (const goal of goals) {
try {
const result = await evaluateGoal(goal.id);
assignmentsEvaluated += result.evaluated;
} catch (error) {
console.error(`runProgramGoalEvaluationSweep: failed to evaluate goal ${goal.id}`, error);
}
}
return { goalsEvaluated: goals.length, assignmentsEvaluated };
}
// ─── Reminder scan ───────────────────────────────────────────────────────────
function getWholeDaysUntil(now: Date, target: Date): number {
const ms = target.getTime() - now.getTime();
return Math.ceil(ms / DAY_MS);
}
/**
* Scan all active goal assignments and send a reminder email when
* `daysUntilDue` matches an entry in the goal's `reminderDaysBefore` cadence
* (mirrors `runComplianceReminderScan`).
*
* Idempotency: the email worker idempotency key is keyed on assignment id +
* the day bucket so a second run on the same day does not re-send.
*/
export async function runProgramGoalReminderScan(): Promise<{
scanned: number;
remindersEnqueued: number;
}> {
const now = new Date();
const rows = await listAssignmentsForReminderScan();
let remindersEnqueued = 0;
for (const row of rows) {
if (!row.assignment.dueDate) continue;
if (!row.email) continue;
const dueDate = new Date(row.assignment.dueDate);
if (Number.isNaN(dueDate.getTime())) continue;
const daysUntilDue = getWholeDaysUntil(now, dueDate);
const reminderDays = row.goal.reminderDaysBefore ?? [];
const isOverdueDay = daysUntilDue === 0;
const matchesReminder = (daysUntilDue > 0 && reminderDays.includes(daysUntilDue)) || isOverdueDay;
if (!matchesReminder) continue;
const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
const dayKey = now.toISOString().slice(0, 10);
try {
await enqueueTransactionalEmail('programGoalReminder', {
to: row.email,
fields: {
orgName: row.organizationName,
programName: row.programName,
goalTitle: row.goal.title,
daysUntilDue,
completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}`
},
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
});
remindersEnqueued += 1;
} catch (error) {
console.error(`runProgramGoalReminderScan: enqueue failed for assignment ${row.assignment.id}`, error);
}
}
return { scanned: rows.length, remindersEnqueued };
}
// ─── LMS read ────────────────────────────────────────────────────────────────
export async function getMyGoals(profileId: string) {
return getAssignmentsForProfile(profileId);
}
// ─── Org-wide overview ───────────────────────────────────────────────────────
/**
* Cross-program goal roll-up for the org owner. Lists every active goal in the
* org with its per-status counts so the dashboard can group by program.
*/
export async function getOrgGoalsOverview(organizationId: string) {
const goals = await getProgramGoalsByOrg(organizationId);
if (goals.length === 0) {
return { goals: [] };
}
const counts = await countAssignmentsByStatus(goals.map((goal) => goal.id));
return {
goals: goals.map((goal) => {
const statusCounts = counts.get(goal.id) ?? {};
const total = Object.values(statusCounts).reduce((sum, value) => sum + (value ?? 0), 0);
const completed = statusCounts.completed ?? 0;
const overdue = statusCounts.overdue ?? 0;
const atRisk = statusCounts.at_risk ?? 0;
const onTrack = total - overdue - atRisk;
const onTrackPct = total === 0 ? 0 : Math.round((onTrack / total) * 100);
return {
goalId: goal.id,
programId: goal.programId,
programName: goal.programName,
title: goal.title,
type: goal.type,
deadlineKind: goal.deadlineKind,
totalLearners: total,
completedCount: completed,
inProgressCount: statusCounts.in_progress ?? 0,
atRiskCount: atRisk,
overdueCount: overdue,
notStartedCount: statusCounts.not_started ?? 0,
waivedCount: statusCounts.waived ?? 0,
onTrackPct
};
})
};
}
+52
View File
@@ -0,0 +1,52 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getProgramById } from '@cio/db/queries/program';
import type { TInviteStudentsToProgram, TAssignExistingStudentsToProgram } from '@cio/utils/validation/program';
import { assignAudienceToCourses, importAudienceMembers } from '@api/services/organization/audience';
/**
* Invite students to a single program by email CSV.
*
* Thin wrapper that hardcodes `programIds = [programId]` and delegates to
* `importAudienceMembers`. Lets a Program ADMIN/TUTOR (or org admin) issue org
* invites scoped to *their own* program, without granting org-wide invite rights.
*/
export async function inviteStudentsToProgram(
programId: string,
data: TInviteStudentsToProgram,
invitedByProfileId: string
) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return importAudienceMembers(
program.organizationId,
{
recipientCsv: data.recipientCsv,
programIds: [programId],
sendEmail: data.sendEmail,
allCourses: false,
allPrograms: false
},
invitedByProfileId
);
}
/**
* Assign existing student profiles (already members of the org audience) to a
* single program. Same scoping logic as `inviteStudentsToProgram` — restricted
* to one program at a time so the caller's program-admin permission is enough.
*/
export async function assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram) {
const program = await getProgramById(programId);
if (!program) {
throw new AppError('Program not found', ErrorCodes.PROGRAM_NOT_FOUND, 404);
}
return assignAudienceToCourses(program.organizationId, {
profileIds: data.profileIds,
programIds: [programId],
sendEmail: data.sendEmail
});
}
+15 -19
View File
@@ -12,7 +12,8 @@ import type {
TSubmissionGradesUpdate,
TSubmissionUpdate
} from '@cio/utils/validation/submission';
import { buildEmailFromName, deliverEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import {
createSubmission,
deleteSubmission,
@@ -977,14 +978,13 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
`;
}
await deliverEmail([
{
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update',
content
}
]);
content,
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
});
}
/**
@@ -1035,18 +1035,14 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
</div>
`;
// Send email to all tutors
const emailPromises = tutorsResult.map((tutor) => {
if (!tutor.email) return Promise.resolve();
return deliverEmail([
{
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutor.email,
subject: `[Submitted]: ${exercise.title}`,
content
}
]);
});
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
if (tutorEmails.length === 0) return;
await Promise.all(emailPromises);
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutorEmails,
subject: `[Submitted]: ${exercise.title}`,
content,
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
});
}
+53 -213
View File
@@ -4,14 +4,11 @@ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@
import { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans';
import {
ZWidgetPayload,
getLayoutCourseLimit,
buildWidgetPayload as buildWidgetPayloadShared,
normalizeWidgetConfig,
resolveWidgetPlanGatedFields,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourseTag
type BuildWidgetPayloadCourse,
type TWidgetPayload
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
@@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string {
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
}
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return '';
}
@@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st
return csstree.generate(ast);
}
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) {
return `https://${customDomain}`;
}
@@ -174,6 +171,28 @@ export function getWidgetEmbedCode(publicKey: string): string {
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
}
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
export function formatCourseForWidget(
course: OrgCourse,
tags: BuildWidgetPayloadCourse['tags']
): BuildWidgetPayloadCourse {
return {
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags
};
}
export async function buildWidgetPayload(
widget: TWidget,
overrideSelectedCourseIds?: string[]
@@ -184,216 +203,47 @@ export async function buildWidgetPayload(
}
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.planName);
const planName = activePlan?.planName ?? null;
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
const selectedCourseIds =
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const allPublishedCourses = allCourses.items.filter((course) => course.isPublished);
const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index]));
let resolvedCourses =
widget.selectionMode === 'manual'
? allCourses.items.filter((course) => manualOrderLookup.has(course.id))
: allPublishedCourses;
const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId,
resolvedCourses.map((course) => course.id)
allCoursesResult.items.map((course) => course.id)
);
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) {
resolvedCourses = resolvedCourses.filter((course) => {
const courseTags = tagsByCourseId[course.id] ?? [];
const courseTagIds = new Set(courseTags.map((tag) => tag.id));
const includesRequired =
normalizedConfig.filters.includeTagIds.length === 0 ||
normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId));
const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId));
return includesRequired && excludesBlocked;
});
}
if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) =>
(manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) -
(manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER)
const allCourses = allCoursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
);
} else if (normalizedConfig.sortBy === 'title') {
resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title));
} else {
resolvedCourses = [...resolvedCourses].sort(
(courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime()
);
}
const layoutType = widget.layoutType as TWidgetLayoutType;
const layoutOptions = normalizedConfig.layoutOptions;
// Hoist the configured "main" course to the front for layouts that have a hero slot,
// so the rest of the slice/limit logic can treat slot 0 as the featured course.
if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) {
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId);
if (featuredIndex > 0) {
const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1);
resolvedCourses = [featuredCourse, ...resolvedCourses];
}
} else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) {
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId);
if (mainIndex > 0) {
const [mainCourse] = resolvedCourses.splice(mainIndex, 1);
resolvedCourses = [mainCourse, ...resolvedCourses];
}
}
// Cap the course list according to the active layout's limit. Category shelf
// builds its own grouped slices below from the already-filtered universe.
if (layoutType !== 'category_shelf') {
const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions);
resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1));
} else {
resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions));
}
// Build payload-specific aggregates.
const heroCourseId =
layoutType === 'primary_course'
? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null)
: layoutType === 'editorial_spotlight'
? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null)
: null;
const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName);
const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return ZWidgetPayload.parse({
version: 'v1',
widgetId: widget.id,
return buildWidgetPayloadShared({
widget: {
id: widget.id,
publicKey: widget.publicKey,
layoutType: widget.layoutType,
selectionMode: widget.selectionMode,
config: normalizedConfig
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
layoutType,
selectionMode: widget.selectionMode,
design: {
...normalizedConfig,
advanced: {
...normalizedConfig.advanced,
customCss: sanitizedCustomCss
}
},
orgBaseUrl,
allCourses,
selectedCourseIds,
planGatedFields,
labels: {
loadMoreLabel: normalizedConfig.content.loadMoreLabel,
poweredByLabel: 'Powered by Classroomio'
},
courses: resolvedCourses.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
rating: null,
ratingCount: 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? [],
featured: heroCourseId ? course.id === heroCourseId : false,
url: `${orgBaseUrl}/course/${course.slug ?? course.id}`
})),
tagPool,
categories,
timestamp: Date.now()
planName,
sanitizedCustomCss
});
}
function buildTagPool(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCourseTag[] {
if (layoutType !== 'tag_filter') {
return [];
}
const allTagsFromCourses = new Map<string, TWidgetPayloadCourseTag>();
for (const course of courses) {
for (const tag of tagsByCourseId[course.id] ?? []) {
if (!allTagsFromCourses.has(tag.id)) {
allTagsFromCourses.set(tag.id, tag);
}
}
}
if (config.layoutOptions.tagFilter.tagSource === 'manual') {
const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds);
return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id));
}
return [...allTagsFromCourses.values()];
}
function buildCategoriesFromShelf(
layoutType: TWidgetLayoutType,
config: ReturnType<typeof normalizeWidgetConfig>,
courses: Array<{ id: string }>,
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
): TWidgetPayloadCategory[] {
if (layoutType !== 'category_shelf') {
return [];
}
const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds;
if (orderedCategoryIds.length === 0) {
return [];
}
// Reverse-lookup tag info from the tags-by-course map.
const tagsById = new Map<string, TWidgetPayloadCourseTag>();
for (const tags of Object.values(tagsByCourseId)) {
for (const tag of tags) {
tagsById.set(tag.id, tag);
}
}
const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory;
return orderedCategoryIds
.map((tagId) => {
const tagInfo = tagsById.get(tagId);
if (!tagInfo) {
return null;
}
const matchingCourses = courses.filter((course) =>
(tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId)
);
return {
tagId,
name: tagInfo.name,
slug: tagInfo.slug,
color: tagInfo.color,
courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id)
} satisfies TWidgetPayloadCategory;
})
.filter((entry): entry is TWidgetPayloadCategory => entry !== null);
}
export async function listWidgetAvailableEditorData(orgId: string) {
const [coursesResult, tagGroups, activePlan] = await Promise.all([
getOrgCourses({ orgId, limit: 200 }),
@@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) {
);
return {
availableCourses: coursesResult.items.map((course) => ({
id: course.id,
slug: course.slug ?? course.id,
title: course.title,
description: course.description,
imageUrl: course.bannerImage || course.logo || null,
isPublished: course.isPublished,
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
lessonCount: course.lessonCount,
exerciseCount: course.exerciseCount ?? 0,
courseType: course.type,
createdAt: course.createdAt ?? new Date().toISOString(),
tags: tagsByCourseId[course.id] ?? []
})),
availableCourses: coursesResult.items.map((course) =>
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
),
availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({
id: tag.id,
@@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) {
groupId: group.id
}))
),
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'),
planName: activePlan?.planName ?? null
};
}
+19 -35
View File
@@ -19,14 +19,15 @@ import {
getDefaultWidgetConfig,
normalizeWidgetConfig,
type TCreateWidget,
type TPreviewWidget,
type TRollbackWidget,
type TUpdateWidget
} from '@cio/utils/validation/widget';
import { getOrganizationById } from '@cio/db/queries/organization';
import {
buildWidgetPayload,
generateWidgetPublicKey,
getCourseBaseUrl,
getCourseWidgetScriptUrl,
getWidgetEmbedCode,
listWidgetAvailableEditorData
@@ -82,26 +83,37 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([
const organization = await getOrganizationById(orgId);
if (!organization) {
throw new AppError('Organization not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const [selectedCourses, versions, editorData] = await Promise.all([
listWidgetCourses(widget.id),
listWidgetVersions(orgId, widget.id),
listWidgetAvailableEditorData(orgId),
buildWidgetPayload(widget)
listWidgetAvailableEditorData(orgId)
]);
return {
widget: {
...widget,
config: normalizeWidgetConfig(widget.config as Record<string, unknown>),
config: normalizeWidgetConfig(widget.config as Record<string, unknown>, editorData.planName),
selectedCourseIds: selectedCourses.map((course) => course.courseId),
embedCode: getWidgetEmbedCode(widget.publicKey),
publicScriptUrl: getCourseWidgetScriptUrl()
},
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
orgBaseUrl: getCourseBaseUrl(organization.siteName ?? '', organization.customDomain),
availableCourses: editorData.availableCourses,
availableTags: editorData.availableTags,
previewPayload,
versions,
planGatedFields: editorData.planGatedFields
planGatedFields: editorData.planGatedFields,
planName: editorData.planName
};
} catch (error) {
if (error instanceof AppError) {
@@ -285,34 +297,6 @@ export async function rollbackOrganizationWidget(
}
}
export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) {
try {
const widget = await getWidgetById(orgId, widgetId);
if (!widget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
}
const overriddenWidget = {
...widget,
layoutType: data.layoutType ?? widget.layoutType,
selectionMode: data.selectionMode ?? widget.selectionMode,
config: data.config ?? widget.config
};
return buildWidgetPayload(overriddenWidget, data.selectedCourseIds);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to build widget preview',
ErrorCodes.WIDGET_FETCH_FAILED,
500
);
}
}
export async function getPublishedWidgetPayload(publicKey: string) {
try {
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);
+56 -104
View File
@@ -1,114 +1,66 @@
import type { TCertificateDownload } from '@cio/utils/validation/course';
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
import {
DEFAULT_CERTIFICATE_DESIGN,
renderCertificate,
resolveTemplateId,
type CertificateDesign,
type CertificateRenderData
} from '@cio/certificates';
export interface CertificateGenerationResult {
success: boolean;
buffer?: Buffer;
error?: string;
import { getCloudflarePdfBuffer, getCloudflarePngBuffer } from '@api/utils/cloudflare';
export interface CertificateRenderInput {
design: CertificateDesign;
data: CertificateRenderData;
}
export const generateCertificate = async (data: TCertificateDownload) => {
const { html, styles } = generateCertificateHtml(data);
return getCloudflarePdfBuffer(html, styles);
};
/**
* Coerces a stored `course.certificate` JSONB blob (legacy or current) into a
* complete `CertificateDesign` suitable for `renderCertificate`.
*/
export function resolveCertificateDesign(stored: unknown): CertificateDesign {
const blob = stored && typeof stored === 'object' ? (stored as Record<string, unknown>) : {};
const legacyTheme = typeof blob.theme === 'string' ? (blob.theme as string) : undefined;
const storedDesign =
blob.design && typeof blob.design === 'object' ? (blob.design as Partial<CertificateDesign>) : undefined;
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const templateId = resolveTemplateId(storedDesign?.templateId ?? legacyTheme);
const html = `
<html>
<head>
<meta charset="utf-8">
<title>Certificate of Completion</title>
</head>
<body>
<div class="certificate">
<div class="header">
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
<h1 class="title">Certificate of Completion</h1>
</div>
<div class="content">
<h2 class="student-name">${studentName}</h2>
<p class="course-name">${courseName}</p>
<p class="description">${courseDescription}</p>
</div>
<div class="footer">
<div class="signature">
<p>${facilitator || 'Course Facilitator'}</p>
<p>${orgName}</p>
</div>
<p class="date">${new Date().toLocaleDateString()}</p>
</div>
</div>
</body>
</html>
`;
const accentColor =
storedDesign?.accentColor && /^#[0-9a-fA-F]{6}$/.test(storedDesign.accentColor)
? storedDesign.accentColor
: DEFAULT_CERTIFICATE_DESIGN.accentColor;
const styles = `
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f5f5f5;
const storedSignatories = Array.isArray(storedDesign?.signatories) ? storedDesign?.signatories : undefined;
const signatories: CertificateDesign['signatories'] = [
{
name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
},
{
name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
}
.certificate {
width: 1123px;
height: 794px;
margin: 0 auto;
background-color: white;
position: relative;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding: 20px;
}
.logo {
max-width: 200px;
margin-bottom: 20px;
}
.title {
font-size: 36px;
color: #333;
margin-bottom: 10px;
}
.content {
text-align: center;
padding: 40px;
}
.student-name {
font-size: 48px;
color: #2c3e50;
margin-bottom: 20px;
}
.course-name {
font-size: 24px;
color: #34495e;
margin-bottom: 10px;
}
.description {
font-size: 18px;
color: #7f8c8d;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 40px;
width: 100%;
text-align: center;
}
.signature {
margin-top: 20px;
}
.date {
margin-top: 10px;
color: #95a5a6;
}
`;
const pdf = {
html: html,
styles: styles
];
return {
templateId,
accentColor,
subtitle: storedDesign?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
descriptionOverride: storedDesign?.descriptionOverride,
signatories,
idFormat: storedDesign?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
};
}
return pdf;
export async function generateCertificatePdf(input: CertificateRenderInput) {
const { html, styles } = renderCertificate(input.design, input.data);
return getCloudflarePdfBuffer(html, styles);
}
export async function generateCertificatePng(input: CertificateRenderInput) {
const { html, styles } = renderCertificate(input.design, input.data);
return getCloudflarePngBuffer(html, styles);
}
+34
View File
@@ -26,3 +26,37 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
}
};
/**
* Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint.
* The viewport is fixed to 1100x780 to match the certificate canvas.
*/
export const getCloudflarePngBuffer = async (html: string, styles?: string) => {
console.log('Generating PNG with Cloudflare API...');
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/screenshot`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
},
body: JSON.stringify({
html,
addStyleTag: styles ? [{ content: styles }] : undefined,
viewport: { width: 1100, height: 780, deviceScaleFactor: 2 },
screenshotOptions: { type: 'png', omitBackground: false, fullPage: false }
})
}
);
console.log('PNG response status:', response.status);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('Error generating PNG:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to generate PNG');
}
};
+5 -3
View File
@@ -5,7 +5,8 @@ import {
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
export function calcCourseProgressPercent(params: {
lessonsCompleted: number;
@@ -69,7 +70,7 @@ export function scheduleCertificationCompletionWork(params: {
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', {
await enqueueTransactionalEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
@@ -78,7 +79,8 @@ export function scheduleCertificationCompletionWork(params: {
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-completion:${groupMemberId}`
});
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
@@ -132,3 +132,15 @@ export const agentUploadKeyGenerator = (c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:agent:upload`;
};
/**
* Redis keys for engagement analytics read endpoints (landing-stats, funnel,
* country breakdown, time-to-enrollment). Value: JSON.
*/
export function dashAnalyticsKey(route: string, orgId: string, days: number, extra?: string): string {
const suffix = extra ? `:${extra}` : '';
return `dash:analytics:${route}:${orgId}:${days}${suffix}`;
}
/** TTL for engagement analytics caches (10 min). */
export const DASH_ANALYTICS_TTL_SECONDS = 600;
+16
View File
@@ -156,6 +156,22 @@ export async function generateUploadPresignedUrl(
return presignedUrl;
}
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
const client = getPresignS3Client();
const command = new GetObjectCommand({
Bucket: bucketName,
Key: key
}) as GetSignedUrlParameters[1];
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
});
}
/**
* Generates presigned URLs for video download
* @param keys Array of video keys
+8 -2
View File
@@ -25,7 +25,11 @@
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"]
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"],
"@cio/jobs": ["./node_modules/@cio/jobs/dist/index.d.ts"],
"@cio/jobs/*": ["./node_modules/@cio/jobs/dist/*"],
"@cio/analytics": ["./node_modules/@cio/analytics/dist/index.d.ts"],
"@cio/analytics/*": ["./node_modules/@cio/analytics/dist/*"]
}
},
"include": ["src/**/*"],
@@ -33,7 +37,9 @@
"references": [
{ "path": "../../packages/db" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/email" }
{ "path": "../../packages/email" },
{ "path": "../../packages/jobs" },
{ "path": "../../packages/analytics" }
],
"tsc-alias": {
"resolveFullPaths": true,
+20
View File
@@ -0,0 +1,20 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var node_path_1 = require('node:path');
var config_1 = require('vitest/config');
exports.default = (0, config_1.defineConfig)({
resolve: {
alias: {
'@api': node_path_1.default.resolve(__dirname, 'src')
}
},
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
});
+3
View File
@@ -21,7 +21,10 @@
"dependencies": {
"@ai-sdk/svelte": "^4.0.158",
"@better-auth/sso": "1.4.18",
"@cio/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/api": "workspace:*",
"@cio/certificates": "workspace:*",
"@cio/db": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/ui": "workspace:*",
+3 -3
View File
@@ -114,11 +114,11 @@ html .dark {
.prose p,
.prose li,
.prose blockquote {
@apply leading-7;
@apply leading-relaxed;
}
.prose p {
@apply my-0;
@apply mb-4;
}
.prose a {
@@ -236,7 +236,7 @@ body {
iframe.iframe {
width: 100%;
max-width: 800px;
max-width: 48rem;
margin: 0 auto;
height: 450px;
border: 1px solid gray;
+19
View File
@@ -5,6 +5,21 @@ import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes';
const ANALYTICS_SESSION_COOKIE = 'cio_aid';
const ANALYTICS_SESSION_MAX_AGE = 60 * 60 * 24 * 90;
function ensureAnalyticsSessionCookie(cookies: Parameters<Handle>[0]['event']['cookies']) {
if (cookies.get(ANALYTICS_SESSION_COOKIE)) return;
cookies.set(ANALYTICS_SESSION_COOKIE, crypto.randomUUID(), {
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: true,
maxAge: ANALYTICS_SESSION_MAX_AGE
});
}
export const handle: Handle = async (args) => {
const { event } = args;
const sessionData = await getSessionData(event.cookies);
@@ -13,6 +28,10 @@ export const handle: Handle = async (args) => {
event.locals = sessionData;
}
if (!event.url.pathname.includes('/api')) {
ensureAnalyticsSessionCookie(event.cookies);
}
let response: Response;
if (event.url.pathname.includes('/api')) {
@@ -0,0 +1,82 @@
import type {
AccountUsage,
AccountWorkspaceLimits,
AccountWorkspaceList,
CreateAccountWorkspaceRequest,
DeleteAccountWorkspaceRequest,
GetAccountUsageRequest,
ListAccountWorkspacesRequest
} from '../utils/types';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
class AccountApi extends BaseApiWithErrors {
workspaces = $state<AccountWorkspaceList>([]);
limits = $state<AccountWorkspaceLimits | null>(null);
usage = $state<AccountUsage | null>(null);
async listWorkspaces() {
return this.execute<ListAccountWorkspacesRequest>({
requestFn: () => classroomio.account.workspaces.$get(),
logContext: 'fetching account workspaces',
onSuccess: (response) => {
this.workspaces = response.data.workspaces;
this.limits = response.data.limits;
}
});
}
async loadUsage() {
return this.execute<GetAccountUsageRequest>({
requestFn: () => classroomio.account.usage.$get(),
logContext: 'fetching account usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createWorkspace(fields: TCreateWorkspace) {
const parsed = ZCreateWorkspace.safeParse(fields);
if (!parsed.success) {
this.errors = mapZodErrorsToTranslations(parsed.error);
return;
}
return this.execute<CreateAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces.$post({ json: parsed.data }),
logContext: 'creating workspace',
onSuccess: async (response) => {
this.workspaces = [...this.workspaces, response.data];
snackbar.success('snackbar.account.workspaceCreated');
await this.listWorkspaces();
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
async deleteWorkspace(workspaceId: string) {
return this.execute<DeleteAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces[':workspaceId'].$delete({ param: { workspaceId } }),
logContext: 'deleting workspace',
onSuccess: () => {
this.workspaces = this.workspaces.filter((row) => row.id !== workspaceId);
snackbar.success('snackbar.account.workspaceDeleted');
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
}
export const accountApi = new AccountApi();
@@ -0,0 +1,2 @@
export { default as WorkspacesPage } from './pages/workspaces.svelte';
export { accountApi } from './api/account.svelte';
@@ -0,0 +1,190 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Spinner } from '@cio/ui/base/spinner';
import * as Dialog from '@cio/ui/base/dialog';
import * as Field from '@cio/ui/base/field';
import { accountApi } from '$features/account/api/account.svelte';
import { currentOrg, isPrimaryWorkspace } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let isCreateOpen = $state(false);
let formName = $state('');
let formSiteName = $state('');
let confirmDeleteId = $state<string | null>(null);
$effect(() => {
if (!$currentOrg.id) return;
accountApi.listWorkspaces();
accountApi.loadUsage();
});
const limits = $derived(accountApi.limits);
const usage = $derived(accountApi.usage);
const workspaces = $derived(accountApi.workspaces);
const ctaState = $derived.by(() => {
if (!limits) return { kind: 'loading' as const };
if (limits.requiresUpgrade) {
return { kind: 'upgrade' as const };
}
if (!limits.canCreate) {
return { kind: 'limit' as const, allowance: limits.allowance };
}
return { kind: 'create' as const, used: limits.used, allowance: limits.allowance };
});
async function onCreate() {
await accountApi.createWorkspace({ name: formName, siteName: formSiteName });
if (accountApi.success) {
formName = '';
formSiteName = '';
isCreateOpen = false;
}
}
async function onDelete(id: string) {
await accountApi.deleteWorkspace(id);
confirmDeleteId = null;
}
</script>
{#if !$isPrimaryWorkspace}
<div class="ui:text-muted-foreground rounded border p-4">
{$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })}
</div>
{:else}
<Field.Group class="w-full max-w-2xl px-2">
{#if usage}
<Field.Set>
<div class="rounded border p-4">
<p class="text-sm">
{$t('account.workspaces.usage_banner', {
learners: usage.learnerCount,
tokens: usage.tokensUsedThisMonth,
workspaces: usage.workspaceCount
})}
</p>
</div>
</Field.Set>
<Field.Separator />
{/if}
<Field.Set>
<div class="flex items-center justify-between">
<Field.Legend>{$t('account.workspaces.page_title')}</Field.Legend>
{#if ctaState.kind === 'create'}
<Button onclick={() => (isCreateOpen = true)}>
{$t('account.workspaces.create_cta')}
<span class="ui:text-muted-foreground ml-2">
{$t('account.workspaces.create_cta_used', { used: ctaState.used, allowance: ctaState.allowance })}
</span>
</Button>
{:else if ctaState.kind === 'limit'}
<Button disabled variant="outline">
{$t('account.workspaces.create_cta_disabled_limit', { allowance: ctaState.allowance })}
</Button>
{:else if ctaState.kind === 'upgrade'}
<Button variant="outline" href="/pricing">
{$t('account.workspaces.create_cta_disabled_upgrade')}
<span class="ml-2 underline">{$t('account.workspaces.upgrade_link')}</span>
</Button>
{/if}
</div>
<Field.Group>
{#if accountApi.isLoading && workspaces.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else}
{#each workspaces as workspace}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<p class="text-sm font-medium">{workspace.name}</p>
<span class="ui:text-muted-foreground text-xs">{workspace.siteName}</span>
{#if workspace.isPrimary}
<Badge variant="secondary">{$t('account.workspaces.primary_badge')}</Badge>
{/if}
{#if workspace.readOnlyUntil}
<Badge variant="outline" class="text-yellow-700">{$t('account.workspaces.read_only_badge')}</Badge>
{/if}
</div>
{#if !workspace.isPrimary}
<Button
variant="ghost"
class="text-red-500 hover:text-red-700"
onclick={() => (confirmDeleteId = workspace.id)}
>
{$t('account.workspaces.delete_action')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
{/if}
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.create_modal_title')}</Dialog.Title>
</Dialog.Header>
<Field.Group>
<Field.Field>
<Field.Label>{$t('account.workspaces.name_label')}</Field.Label>
<Input bind:value={formName} />
{#if accountApi.errors.name}
<Field.Error>{accountApi.errors.name}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('account.workspaces.site_name_label')}</Field.Label>
<Input bind:value={formSiteName} />
{#if accountApi.errors.siteName}
<Field.Error>{accountApi.errors.siteName}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCreateOpen = false)}>{$t('account.workspaces.cancel')}</Button>
<Button onclick={onCreate} loading={accountApi.isLoading} disabled={accountApi.isLoading}>
{$t('account.workspaces.create_modal_submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root open={confirmDeleteId !== null} onOpenChange={(open) => !open && (confirmDeleteId = null)}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.delete_confirm_title')}</Dialog.Title>
</Dialog.Header>
<p class="ui:text-muted-foreground">{$t('account.workspaces.delete_confirm_body')}</p>
<Dialog.Footer>
<Button variant="outline" onclick={() => (confirmDeleteId = null)}>{$t('account.workspaces.cancel')}</Button>
<Button
variant="destructive"
onclick={() => confirmDeleteId && onDelete(confirmDeleteId)}
loading={accountApi.isLoading}
disabled={accountApi.isLoading}
>
{$t('account.workspaces.delete_action')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,14 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces.$get;
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
export type AccountWorkspaceList = ListSuccess['data']['workspaces'];
export type AccountWorkspaceLimits = ListSuccess['data']['limits'];
export type AccountWorkspace = AccountWorkspaceList[number];
export type AccountUsage = UsageSuccess['data'];
@@ -0,0 +1,33 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { snackbar } from '$features/ui/snackbar/store';
import type { GenerateTextRequest } from '../utils/types';
export class TextGenerationApi extends BaseApiWithErrors {
async generate(
prompt: string,
tone: string,
format: 'plain' | 'html' = 'plain',
context?: string,
courseId?: string
): Promise<string | null> {
let generatedText: string | null = null;
await this.execute<GenerateTextRequest>({
requestFn: () =>
classroomio.agent['generate-text'].$post({
json: { prompt, tone: tone as 'professional' | 'casual' | 'expert' | 'friendly', format, context, courseId }
}),
logContext: 'generating text',
onSuccess: (response) => {
generatedText = response.data.text;
},
onError: () => {
snackbar.error('Failed to generate text. Please try again.');
}
});
return generatedText;
}
}
export const textGenerationApi = new TextGenerationApi();
@@ -0,0 +1,22 @@
<script lang="ts">
import { AIGeneratePopover } from '@cio/ui/custom/ai-generate-popover';
import { textGenerationApi } from '../api/text-generation.svelte';
interface Props {
onInsert: (text: string) => void;
context?: string;
format?: 'plain' | 'html';
courseId?: string;
align?: 'start' | 'center' | 'end';
}
let { onInsert, context, format = 'plain', courseId, align = 'right' }: Props = $props();
</script>
<AIGeneratePopover
{align}
onGenerate={async (prompt, tone) => {
const text = await textGenerationApi.generate(prompt, tone, format, context, courseId);
if (text) onInsert(text);
}}
/>
@@ -0,0 +1,4 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GenerateTextRequest = (typeof classroomio.agent)['generate-text']['$post'];
export type GenerateTextSuccess = Extract<InferResponseType<GenerateTextRequest>, { success: true }>;
@@ -1,12 +1,14 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
import ChatInput from '$features/ai-assistant/chat-input.svelte';
import { resolve } from '$app/paths';
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import {
getAgentToolInput,
getAgentToolName,
getAgentToolResult,
getAgentToolStatus,
@@ -14,12 +16,16 @@
type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts';
import {
isAiAssistantOpen,
chatDraft,
clearChatDraft,
initialChatModel,
initialChatPrompt,
initialChatTemplateId,
clearInitialChatModel,
clearInitialChatPrompt
clearInitialChatPrompt,
clearInitialChatTemplateId
} from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte';
@@ -28,23 +34,24 @@
import { getMentionableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user';
import type {
AiAssistantMessage,
AiAssistantMessageMetadata,
AiAssistantTemplateMetadata,
UploadedDocument
} from '$features/ai-assistant/utils/types';
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
import {
AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH,
AI_COURSE_CHAT_MAX_WIDTH,
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
STUDENT_QUICK_ACTION_ENTRIES
} from '$features/ai-assistant/utils/constants';
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
import {
AGENT_MODELS,
AGENT_MODEL_IDS,
@@ -53,14 +60,18 @@
type AgentModelId
} from '@cio/utils/agent-models';
/** Every ~30% / 60% / 90% of tool steps completed, refetch course so UI reflects partial mutations */
const AGENT_STEP_PROGRESS_REFRESH_RATIOS = [0.3, 0.6, 0.9] as const;
/** Completed-step thresholds already refreshed for this streaming session */
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
let lastSeenStreamingFlag = false;
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
interface Props {
/** Course id from route / layout `data` (authoritative for this page). */
courseId: string;
}
let { courseId }: Props = $props();
// Read course id from the route. The chat panel is only mounted inside the
// course content layout, so `page.params.id` is always the active course.
const courseId = $derived(page.params?.id as string);
// Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -69,15 +80,13 @@
let inputValue = $state('');
let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false);
let contextFullBusy = $state<null | 'compact' | 'new_chat'>(null);
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
let statusFetchedForCourseId: string | null = $state(null);
let conversationsLoadedForCourseId: string | null = $state(null);
let activeConversationId: string | null = $state(null);
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
let hasLoadedChatWidth = $state(false);
let isChatResizing = $state(false);
let stopChatResize: (() => void) | null = null;
let chatShellElement: HTMLDivElement | null = null;
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
@@ -120,15 +129,6 @@
}
});
function clampChatWidth(width: number) {
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
}
function clearChatResizeListeners() {
stopChatResize?.();
stopChatResize = null;
}
function getStorageKey(courseId: string) {
return `ai-chat-active-${courseId}`;
}
@@ -197,15 +197,25 @@
}
}
// Fetch status and load conversations when the panel opens or route `courseId` changes
$effect(() => {
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
async function handleRenameConversation(conversationId: string, title: string) {
const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
return;
if (!updatedTitle) {
const rawError = aiAssistantApi.error;
let message = t.get('ai_assistant.rename_chat_failed');
if (rawError && !rawError.startsWith('{')) {
message = rawError;
}
throw new Error(message);
}
}
// Fetch status and load conversations when the chat panel mounts (the
// SidePanelRail only mounts this component while the panel is active) or
// when the route's courseId changes.
$effect(() => {
if (!courseId) return;
if (statusFetchedForCourseId !== courseId) {
@@ -216,14 +226,20 @@
if (conversationsLoadedForCourseId !== courseId) {
conversationsLoadedForCourseId = courseId;
// If the panel was just opened to start a fresh chat (e.g. via quoteInChat),
// skip the auto-load so the draft effect can call startNewChat itself.
const pendingDraft = get(chatDraft);
if (pendingDraft?.mode === 'new') {
return;
}
const listForCourseId = courseId;
const savedId = getActiveConversationId(listForCourseId);
aiAssistantApi.listConversations(listForCourseId).then(() => {
if (courseId !== listForCourseId) return;
// If the initial-prompt flow already created and claimed a conversation while
// listConversations was in-flight, don't clobber it by loading another one.
if (activeConversationId) return;
if (savedId) {
@@ -323,18 +339,53 @@
}
});
const quickActions = [
'Upload a document to create a course',
'Draft a lesson',
'Generate questions from this lesson',
'Summarize this lesson',
'Help me publish this course'
];
function buildTemplateAnswersSummary(
templateId: CourseTemplateId,
answers: Record<string, string>,
fields: TemplateFormField[]
): string {
const template = getCourseTemplate(templateId);
const registryById = new Map((template?.fields ?? []).map((field) => [field.id, field]));
const lines: string[] = ['Here are my answers for the course template:'];
const seen = new Set<string>();
for (const field of fields) {
if (!field?.id || seen.has(field.id)) {
continue;
}
seen.add(field.id);
const trimmed = answers[field.id]?.trim() ?? '';
if (!trimmed) {
continue;
}
const label = registryById.get(field.id)?.label ?? field.label ?? field.id;
lines.push(`- ${label}: ${trimmed}`);
}
// Capture any answers whose field isn't in the rendered list (defensive — shouldn't happen)
for (const [fieldId, value] of Object.entries(answers)) {
if (seen.has(fieldId)) continue;
const trimmed = value.trim();
if (!trimmed) continue;
const label = registryById.get(fieldId)?.label ?? fieldId;
lines.push(`- ${label}: ${trimmed}`);
}
return lines.join('\n');
}
function handleSend() {
if (!inputValue.trim() || chat.status === 'streaming') return;
const text = inputValue;
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
const messageAttachment = uploadedDocument
? {
documentId: uploadedDocument.id,
@@ -342,6 +393,18 @@
}
: undefined;
const metadata: AiAssistantMessageMetadata = {};
if (messageAttachment) {
metadata.attachment = messageAttachment;
}
if (templateForFirstMessage) {
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
metadata.template = templateMeta;
pendingInitialTemplateId = null;
}
inputValue = '';
// Create conversation eagerly before sending the first message
@@ -357,7 +420,48 @@
chat.sendMessage({
text,
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
...(Object.keys(metadata).length > 0 ? { metadata } : {})
});
}
async function handleSubmitTemplateAnswers(payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
metadata: {
template: {
action: 'submit_template_answers',
templateId: payload.templateId,
answers: payload.answers
}
}
});
}
async function handleSkipTemplateForm(payload: { templateId: CourseTemplateId }) {
if (!courseId || chat.status === 'streaming') {
return;
}
await ensureActiveConversation(courseId);
chat.sendMessage({
text: "I'll answer your questions in chat instead of the form.",
metadata: {
template: {
action: 'skip_template_form',
templateId: payload.templateId
}
}
});
}
@@ -428,8 +532,20 @@
});
}
function handleAskPlanChanges() {
inputValue = '';
function handleAskPlanChanges(message: string) {
const trimmed = message.trim();
if (!trimmed || chat.status === 'streaming') return;
if (!activeConversationId && courseId) {
aiAssistantApi.createConversation(courseId).then((created) => {
if (created) {
setActiveConversationId(courseId, created.id);
}
});
}
chat.sendMessage({ text: trimmed });
}
function handleResume() {
@@ -441,9 +557,94 @@
goto(resolve(route, {}));
}
async function handleStartNewChatWithSummary() {
if (!courseId || contextFullBusy) return;
contextFullBusy = 'new_chat';
try {
const summary = await aiAssistantApi.summarizeConversation(chat.messages as AiAssistantMessage[], courseId);
chat.messages = [];
setActiveConversationId(courseId, null);
const created = await aiAssistantApi.createConversation(courseId);
if (created) {
setActiveConversationId(courseId, created.id);
}
if (summary) {
inputValue = summary;
handleSend();
}
} finally {
contextFullBusy = null;
}
}
async function handleCompactConversation() {
if (!courseId || !activeConversationId || contextFullBusy) return;
contextFullBusy = 'compact';
try {
const compacted = await aiAssistantApi.compactConversation(activeConversationId);
if (compacted && compacted.length > 0) {
chat.messages = compacted as AiAssistantMessage[];
await chat.regenerate();
}
} finally {
contextFullBusy = null;
}
}
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
const status = $derived(aiAssistantApi.status);
const isStudent = $derived(status?.role === 'student');
const tutorStatus = $derived(status?.tutor);
const tutorErrorCode = $derived.by(() => {
if (!chat.error) return null;
const message = chat.error.message ?? '';
if (message.includes('AI_TUTOR_DISABLED')) return 'AI_TUTOR_DISABLED' as const;
if (message.includes('LEARNER_CAP_REACHED')) return 'LEARNER_CAP_REACHED' as const;
if (message.includes('POOL_EXHAUSTED')) return 'POOL_EXHAUSTED' as const;
return null;
});
const tutorBlocked = $derived.by(() => {
if (!isStudent) return null;
if (tutorErrorCode) return tutorErrorCode;
if (tutorStatus && tutorStatus.enabled === false) return 'AI_TUTOR_DISABLED' as const;
if (tutorStatus && tutorStatus.enforced && tutorStatus.capRemaining !== null && tutorStatus.capRemaining <= 0) {
return 'LEARNER_CAP_REACHED' as const;
}
return null;
});
const quickActions = $derived(isStudent ? [...STUDENT_QUICK_ACTION_ENTRIES] : [...AI_ASSISTANT_QUICK_ACTION_ENTRIES]);
// Student-facing per-learner monthly cap (100 messages). Hidden when the agent reports
// no tutor data (e.g. provider unconfigured) so we don't render a 0/0 bar.
const studentMessageUsage = $derived.by(() => {
if (!isStudent || !tutorStatus || tutorStatus.cap == null || tutorStatus.capRemaining == null) {
return null;
}
return {
used: Math.max(0, tutorStatus.cap - tutorStatus.capRemaining),
cap: tutorStatus.cap
};
});
const contextUsage = $derived(
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
);
const mentionItems = $derived(
getMentionableContent(courseApi.course).map((item) => ({
id: item.id,
@@ -455,9 +656,31 @@
// Show an activity card whenever the agent calls any tool.
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
const planExecutionState = $derived.by(() => {
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
const lastMsg = chat.messages[chat.messages.length - 1];
if (!lastAssistantMsg) return null;
if (!lastMsg) return null;
// If the most recent message is from the user, the agent has not produced a
// reply yet — don't show the previous assistant's tool card. While streaming
// is starting up, fall through to the thinking placeholder below.
if (lastMsg.role !== 'assistant') {
if (!isStreaming) return null;
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const lastAssistantMsg = lastMsg;
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit';
@@ -466,9 +689,29 @@
) as AgentToolPart[];
// generate_course_plan renders its own PlanView — exclude it from the card
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
const toolParts = allToolParts.filter((part) => {
const toolName = getAgentToolName(part);
return toolName !== 'generate_course_plan' && toolName !== 'ask_template_questions';
});
if (toolParts.length === 0) return null;
if (toolParts.length === 0) {
if (!isStreaming) return null;
// The agent is mid-turn (reasoning / preparing a tool call) but hasn't emitted
// a real tool part yet. Show a placeholder step so the user always sees activity.
return {
steps: [
{
status: 'in_progress' as const,
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
}
],
currentActionLine: undefined,
isStopped: false,
titleKey: 'ai_assistant.plan_working',
hasMutations: false
};
}
const steps: ProgressStep[] = toolParts.flatMap((part) => {
const toolName = getAgentToolName(part);
@@ -479,9 +722,12 @@
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
const status = getAgentToolStatus(part);
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
const line =
status === 'completed'
? getCompletedToolLine(toolName, result)
: getPendingToolLine(toolName, getAgentToolInput(part));
return [{ label, status }];
return [{ line, status }];
});
const allDone = steps.every((s) => s.status === 'completed');
@@ -494,55 +740,60 @@
const toolName = getAgentToolName(part);
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
});
const title = hasMutations ? 'Applying changes' : 'Working';
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
const currentActionLine = steps.find((s) => s.status === 'in_progress')?.line;
return { steps, currentAction, isStopped, title };
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
});
$effect(() => {
const streamingNow = isStreaming;
if (streamingNow && !lastSeenStreamingFlag) {
agentMutationProgressThresholdsTriggered.clear();
}
lastSeenStreamingFlag = streamingNow;
});
$effect(() => {
if (!isStreaming) {
return;
}
const state = planExecutionState;
if (!state?.hasMutations || state.isStopped) {
return;
}
const total = state.steps.length;
if (total === 0) {
return;
}
const completedStepCount = state.steps.filter((s) => s.status === 'completed').length;
const stepThresholds = [
...new Set(
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
)
].sort((a, b) => a - b);
for (const threshold of stepThresholds) {
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
agentMutationProgressThresholdsTriggered.add(threshold);
refreshCourseStateAfterChat();
}
}
});
const conversationTitle = $derived(
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
);
function handleChatResizePointerDown(event: PointerEvent) {
event.preventDefault();
event.stopPropagation();
clearChatResizeListeners();
const startWidth = chatWidth;
stopChatResize = startResizablePanelDrag({
event,
startWidth,
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
onPreview: (width) => {
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
},
onCommit: ({ width }) => {
chatWidth = width;
},
onDragStart: () => {
isChatResizing = true;
},
onDragEnd: () => {
isChatResizing = false;
stopChatResize = null;
}
});
}
onMount(() => {
try {
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
if (Number.isFinite(storedWidth)) {
chatWidth = clampChatWidth(storedWidth);
}
} catch {
// localStorage unavailable
}
try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
@@ -552,28 +803,22 @@
} catch {
// localStorage unavailable
}
hasLoadedChatWidth = true;
return () => {
clearChatResizeListeners();
};
});
onDestroy(() => {
clearChatResizeListeners();
});
$effect(() => {
const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return;
if (!prompt || !courseId) return;
const model = $initialChatModel;
const templateFromHome = $initialChatTemplateId;
clearInitialChatPrompt();
clearInitialChatModel();
clearInitialChatTemplateId();
tick().then(() => {
pendingInitialTemplateId = templateFromHome ?? null;
if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
@@ -589,21 +834,37 @@
});
$effect(() => {
if (!hasLoadedChatWidth) {
const draft = $chatDraft;
if (!draft || !draft.text) {
return;
}
try {
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
} catch {
// localStorage unavailable
clearChatDraft();
if (draft.mode === 'new') {
void startNewChat().then(() =>
tick().then(() => {
inputValue = `${draft.text}\n\n`;
})
);
return;
}
void tick().then(() => {
const existing = inputValue.trimEnd();
inputValue = existing ? `${draft.text}\n\n${existing}` : `${draft.text}\n\n`;
});
});
</script>
{#snippet chatPanelContent()}
<div class="flex min-h-0 flex-1 flex-col">
<ChatHeader
{tokenUsage}
{isStudent}
{studentMessageUsage}
{conversationTitle}
conversations={aiAssistantApi.conversations}
{activeConversationId}
@@ -611,18 +872,21 @@
onNewChat={startNewChat}
onLoadConversation={loadConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
/>
<ChatMessageList
messages={chat.messages}
status={chat.status}
{isStreaming}
{isStudent}
{courseId}
{planExecutionState}
{quickActions}
onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan}
onAskPlanChanges={handleAskPlanChanges}
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
onSkipTemplateForm={handleSkipTemplateForm}
onStop={handleStop}
onResume={handleResume}
onMentionClick={handleMentionClick}
@@ -633,9 +897,15 @@
{isStreaming}
{isExhausted}
{isUploading}
{contextFullBusy}
compactConversationDisabled={!activeConversationId}
onCompactConversation={handleCompactConversation}
{uploadedDocument}
{mentionItems}
{selectedModel}
{contextUsage}
{isStudent}
{tutorBlocked}
lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error}
onSend={handleSend}
@@ -644,37 +914,6 @@
onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect}
onStartNewChatWithSummary={handleStartNewChatWithSummary}
/>
{/snippet}
{#if $isAiAssistantOpen}
<div
bind:this={chatShellElement}
data-ai-chat-resizing={isChatResizing}
class="contents"
style={`--ai-chat-width: ${chatWidth}px;`}
>
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
<div
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
>
<button
type="button"
aria-label="Resize AI assistant panel"
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
onpointerdown={handleChatResizePointerDown}
>
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
</button>
{@render chatPanelContent()}
</div>
</div>
{/if}
<style>
:global([data-ai-chat-resizing='true'] *) {
transition-property: none !important;
}
</style>
</div>
@@ -4,7 +4,9 @@ import type {
AgentConversationCreateData,
AgentConversationSummary,
AgentStatusData,
AiAssistantMessage
AiAssistantMessage,
CompactConversationRequest,
CompactConversationSuccess
} from '../utils/types';
class AiAssistantApi extends BaseApiWithErrors {
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
return generatedTitle;
}
async renameConversation(conversationId: string, title: string): Promise<string | null> {
let newTitle: string | null = null;
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$patch']>({
requestFn: () =>
classroomio.agent.history[':conversationId'].$patch({
param: { conversationId },
json: { title }
}),
logContext: 'renaming conversation',
onSuccess: (result) => {
newTitle = (result.data as { id: string; title: string }).title;
this.conversations = this.conversations.map((c) => (c.id === conversationId ? { ...c, title: newTitle } : c));
if (this.currentConversation?.id === conversationId) {
this.currentConversation = { ...this.currentConversation, title: newTitle };
}
}
});
return newTitle;
}
async summarizeConversation(messages: AiAssistantMessage[], courseId: string): Promise<string | null> {
let summary: string | null = null;
await this.execute<(typeof classroomio.agent)['summarize']['$post']>({
requestFn: () =>
classroomio.agent.summarize.$post({
json: { messages, courseId }
}),
logContext: 'summarizing conversation',
onSuccess: (result) => {
summary = (result.data as { summary: string }).summary;
}
});
return summary;
}
async compactConversation(conversationId: string): Promise<CompactConversationSuccess['data']['messages'] | null> {
let compacted: CompactConversationSuccess['data']['messages'] | null = null;
await this.execute<CompactConversationRequest>({
requestFn: () =>
classroomio.agent.history[':conversationId'].compact.$post({
param: { conversationId }
}),
logContext: 'compacting conversation',
onSuccess: (result) => {
compacted = result.data.messages;
if (this.currentConversation?.id === conversationId) {
this.currentConversation = {
...this.currentConversation,
messages: compacted as AiAssistantMessage[]
};
}
}
});
return compacted;
}
}
export const aiAssistantApi = new AiAssistantApi();
@@ -1,5 +1,7 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import { Input } from '@cio/ui/base/input';
import { tick } from 'svelte';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus';
@@ -22,8 +24,15 @@
remaining: number;
}
interface StudentMessageUsage {
used: number;
cap: number;
}
interface Props {
tokenUsage: TokenUsage | null;
isStudent: boolean;
studentMessageUsage: StudentMessageUsage | null;
conversations: Conversation[];
activeConversationId: string | null;
conversationTitle: string | null;
@@ -31,25 +40,132 @@
onNewChat: () => void;
onLoadConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
/** Resolves on success; rejects with Error when rename fails (message is user-presentable). */
onRenameConversation: (id: string, title: string) => Promise<void>;
}
let {
tokenUsage,
isStudent,
studentMessageUsage,
conversations,
activeConversationId,
conversationTitle,
isNewChatDisabled,
onNewChat,
onLoadConversation,
onDeleteConversation
onDeleteConversation,
onRenameConversation
}: Props = $props();
let historyPopoverOpen = $state(false);
let editingConversationTitle = $state(false);
let draftConversationTitle = $state('');
let renameConversationError = $state<string | null>(null);
let titleInputRef: HTMLInputElement | null = $state(null);
let renameCommitInFlight = $state(false);
let snapshotConversationIdForRename = $state<string | null>(null);
const canRenameConversation = $derived(
Boolean(activeConversationId && conversationTitle && conversationTitle !== 'New conversation')
);
function handleLoadConversation(id: string) {
onLoadConversation(id);
historyPopoverOpen = false;
}
function cancelRenameConversation() {
if (renameCommitInFlight) {
return;
}
if (!editingConversationTitle) {
return;
}
editingConversationTitle = false;
renameConversationError = null;
draftConversationTitle = conversationTitle ?? '';
snapshotConversationIdForRename = null;
}
$effect(() => {
if (
editingConversationTitle &&
snapshotConversationIdForRename != null &&
activeConversationId !== snapshotConversationIdForRename
) {
cancelRenameConversation();
}
});
async function startRenameConversation() {
if (!canRenameConversation || !activeConversationId || !conversationTitle) {
return;
}
renameConversationError = null;
draftConversationTitle = conversationTitle;
snapshotConversationIdForRename = activeConversationId;
editingConversationTitle = true;
await tick();
titleInputRef?.focus();
titleInputRef?.select();
}
async function commitRenameConversation() {
if (!activeConversationId) {
return;
}
const trimmedTitle = draftConversationTitle.trim();
if (trimmedTitle.length === 0) {
cancelRenameConversation();
return;
}
if (trimmedTitle === conversationTitle) {
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
return;
}
renameCommitInFlight = true;
try {
await onRenameConversation(activeConversationId, trimmedTitle);
editingConversationTitle = false;
renameConversationError = null;
snapshotConversationIdForRename = null;
} catch (renameError) {
const message = renameError instanceof Error ? renameError.message : t.get('ai_assistant.rename_chat_failed');
renameConversationError = message;
} finally {
renameCommitInFlight = false;
}
}
function handleTitleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
void commitRenameConversation();
}
if (event.key === 'Escape') {
event.preventDefault();
cancelRenameConversation();
}
}
</script>
<div class="border-b px-4 py-3">
@@ -59,7 +175,32 @@
<div class="min-w-0">
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
{#if conversationTitle && conversationTitle !== 'New conversation'}
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
{#if editingConversationTitle}
<div class="min-w-0">
<Input
bind:ref={titleInputRef}
bind:value={draftConversationTitle}
class="ui:h-7 ui:min-h-0 ui:px-1.5 ui:py-0 text-xs"
placeholder={$t('ai_assistant.rename_chat_placeholder')}
aria-label={$t('ai_assistant.rename_chat_input_aria')}
onkeydown={handleTitleKeydown}
onblur={cancelRenameConversation}
/>
{#if renameConversationError}
<p class="ui:text-destructive mt-0.5 text-[10px]">{renameConversationError}</p>
{/if}
</div>
{:else}
<button
type="button"
class="ui:text-muted-foreground ui:max-w-full ui:cursor-pointer ui:rounded-md ui:border ui:border-transparent ui:px-1.5 ui:py-0.5 hover:ui:bg-muted/40 hover:ui:border-border truncate text-left text-xs"
aria-label={$t('ai_assistant.rename_chat_aria')}
disabled={!canRenameConversation}
onclick={startRenameConversation}
>
{conversationTitle}
</button>
{/if}
{/if}
</div>
</div>
@@ -103,7 +244,28 @@
</IconButton>
</div>
</div>
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{#if isStudent && studentMessageUsage}
{@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))}
<div class="mt-2">
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
<span
>{studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()}
{$t('ai_assistant.messages_used_label')}</span
>
<span>{usagePercent}%</span>
</div>
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
<div
class="h-1 rounded-full transition-all {usagePercent > 90
? 'bg-red-500'
: usagePercent > 70
? 'bg-amber-500'
: 'ui:bg-primary'}"
style="width: {usagePercent}%"
></div>
</div>
</div>
{:else if !isStudent && tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
<div class="mt-2">
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
@@ -14,6 +17,10 @@
import { openUpgradeModal } from '$lib/utils/functions/org';
import { ModelPicker } from '@cio/ui/custom/model-picker';
import type { AgentModelId } from '@cio/utils/agent-models';
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
import ContextIndicator from './context-indicator.svelte';
import ContextFullState from './context-full-state.svelte';
import type { ContextUsage } from './utils/context-utils';
interface UploadedDocument {
id: string;
@@ -25,17 +32,25 @@
isStreaming: boolean;
isExhausted: boolean;
isUploading: boolean;
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
error: Error | null | undefined;
mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId;
lockedModelIds?: readonly AgentModelId[];
contextUsage: ContextUsage | null;
isStudent?: boolean;
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
onSend: () => void;
onStop: () => void;
onFileSelect: (file: File) => void;
onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void;
onStartNewChatWithSummary: () => void;
onCompactConversation: () => void;
}
let {
@@ -43,23 +58,64 @@
isStreaming,
isExhausted,
isUploading,
contextFullBusy,
compactConversationDisabled = false,
error,
mentionItems,
uploadedDocument,
selectedModel,
lockedModelIds = [],
contextUsage,
isStudent = false,
tutorBlocked = null,
onSend,
onStop,
onFileSelect,
onRemoveDocument,
onSelectModel,
onLockedModelSelect
onLockedModelSelect,
onStartNewChatWithSummary,
onCompactConversation
}: Props = $props();
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
if (reason === 'LEARNER_CAP_REACHED') return t.get('aiTutor.takeABreak.learnerCap');
if (reason === 'POOL_EXHAUSTED') return t.get('aiTutor.takeABreak.poolExhausted');
return t.get('aiTutor.takeABreak.disabled');
}
let fileInputEl: HTMLInputElement | undefined = $state();
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
let wasBusy = $state(false);
function readAgentRunningWarningDismissed() {
if (!browser) {
return false;
}
try {
return localStorage.getItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY) === '1';
} catch {
return false;
}
}
let agentRunningWarningDismissed = $state(readAgentRunningWarningDismissed());
onMount(() => {
agentRunningWarningDismissed = readAgentRunningWarningDismissed();
});
function dismissAgentRunningWarning() {
agentRunningWarningDismissed = true;
try {
localStorage.setItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY, '1');
} catch {
// localStorage unavailable
}
}
$effect(() => {
const isBusy = isStreaming || isUploading;
@@ -102,6 +158,31 @@
return t.get('ai_assistant.mention_lesson');
}
function getUserFriendlyErrorMessage(errorMessage: string): string {
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('quota exceeded') || lowerMessage.includes('rate limit')) {
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)/i);
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) : null;
return waitSeconds
? t.get('ai_assistant.error_rate_limit_with_wait', { seconds: waitSeconds })
: t.get('ai_assistant.error_rate_limit');
}
if (lowerMessage.includes('context length') || lowerMessage.includes('too long')) {
return t.get('ai_assistant.error_context_too_long');
}
if (lowerMessage.includes('network') || lowerMessage.includes('connection')) {
return t.get('ai_assistant.error_network');
}
return errorMessage;
}
const displayErrorMessage = $derived(error ? getUserFriendlyErrorMessage(error.message) : null);
</script>
<input
@@ -112,7 +193,22 @@
onchange={handleFileChange}
/>
<div class="border-t px-3 pt-3 pb-1.5">
{#if tutorBlocked}
<div class="border-t px-3 py-4">
<div class="ui:bg-muted/40 ui:text-muted-foreground rounded-md border px-3 py-3 text-sm">
<p class="ui:text-foreground mb-1 text-sm font-medium">{$t('aiTutor.takeABreak.title')}</p>
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
</div>
</div>
{:else if contextUsage?.isFull && !isStreaming}
<ContextFullState
{contextFullBusy}
{compactConversationDisabled}
{onCompactConversation}
onStartNewChat={onStartNewChatWithSummary}
/>
{:else}
<div class="border-t px-3 pt-3 pb-1.5">
{#if isExhausted}
<div
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
@@ -130,19 +226,29 @@
{/if}
</div>
{:else}
{#if isStreaming}
{#if isStreaming && !agentRunningWarningDismissed && !isStudent}
<div
class="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
class="mb-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
{$t('ai_assistant.agent_running_warning')}
<span class="min-w-0 flex-1">{$t('ai_assistant.agent_running_warning')}</span>
<IconButton
variant="outline"
size="icon-xs"
type="button"
tooltip={t.get('ai_assistant.agent_running_warning_dismiss')}
class="border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
onclick={dismissAgentRunningWarning}
>
<XIcon size={12} />
</IconButton>
</div>
{/if}
{#if error}
{#if displayErrorMessage}
<div
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
>
{error.message}
{displayErrorMessage}
</div>
{/if}
@@ -188,6 +294,7 @@
{/snippet}
{#snippet actions()}
{#if !isStudent}
<button
onclick={handlePaperclipClick}
disabled={isUploading}
@@ -196,14 +303,19 @@
>
<PaperclipIcon size={16} />
</button>
{/if}
<div class="flex-1"></div>
{#if contextUsage && contextUsage.usedTokens > 0}
<ContextIndicator {contextUsage} />
{/if}
{#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} />
</Button>
{:else}
{:else if !isStudent}
<ModelPicker
value={selectedModel}
disabled={isStreaming}
@@ -211,9 +323,14 @@
onChange={onSelectModel}
onLockedSelect={onLockedModelSelect}
/>
{:else}
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
{$t('ai_assistant.send')}
</Button>
{/if}
{/snippet}
</ChatTextarea>
</div>
{/if}
</div>
</div>
{/if}
@@ -5,26 +5,40 @@
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
interface QuickActionOption {
key: string;
prompt: string;
}
interface PlanExecutionState {
title: string;
titleKey: string;
steps: ProgressStep[];
currentAction?: string;
currentActionLine?: ToolLineUi;
isStopped: boolean;
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
hasMutations?: boolean;
}
interface Props {
messages: AiAssistantMessage[];
status: string;
isStreaming: boolean;
isStudent: boolean;
courseId: string;
planExecutionState: PlanExecutionState | null;
quickActions: string[];
quickActions: QuickActionOption[];
onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => void;
onAskPlanChanges: () => void;
onAskPlanChanges: (message: string) => void;
onSubmitTemplateAnswers: (payload: {
templateId: CourseTemplateId;
answers: Record<string, string>;
fields: TemplateFormField[];
}) => void;
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
onStop: () => void;
onResume: () => void;
onMentionClick: (route: string) => void;
@@ -32,14 +46,16 @@
let {
messages,
status,
isStreaming,
isStudent,
courseId,
planExecutionState,
quickActions,
onQuickAction,
onImplementPlan,
onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onStop,
onResume,
onMentionClick
@@ -48,13 +64,34 @@
let messagesContainer: HTMLDivElement | undefined = $state();
let lastMessageCount = $state(0);
let lastStepsCount = $state(0);
let lastCurrentAction = $state('');
let lastCurrentActionSig = $state('');
let lastStreamingSig = $state(0);
const isEmpty = $derived(messages.length === 0);
function scrollToBottom() {
// Total text length of the most recent assistant message — grows as tokens stream in.
// Used to keep the view pinned to the latest text while the AI is typing.
const streamingContentSig = $derived.by(() => {
if (messages.length === 0) return 0;
const last = messages[messages.length - 1];
if (last.role !== 'assistant') return 0;
let total = 0;
for (const part of last.parts ?? []) {
if ((part as { type?: string }).type === 'text') {
total += ((part as { text?: string }).text ?? '').length;
}
}
return total;
});
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
requestAnimationFrame(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
});
}
@@ -67,6 +104,16 @@
scrollToBottom();
});
$effect(() => {
if (!messagesContainer) return;
if (streamingContentSig === lastStreamingSig) return;
lastStreamingSig = streamingContentSig;
// Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor.
scrollToBottom('auto');
});
$effect(() => {
if (!messagesContainer || !planExecutionState) return;
@@ -77,9 +124,10 @@
return;
}
const action = planExecutionState.currentAction ?? '';
if (action !== lastCurrentAction) {
lastCurrentAction = action;
const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
if (actionSig !== lastCurrentActionSig) {
lastCurrentActionSig = actionSig;
scrollToBottom();
}
});
@@ -92,17 +140,17 @@
<div class="flex flex-col items-center gap-2 text-center">
<SparklesIcon size={32} class="ui:text-muted-foreground" />
<p class="ui:text-muted-foreground text-sm">
{$t('ai_assistant.empty_state')}
{$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
{#each quickActions as action (action)}
{#each quickActions as option (option.key)}
<button
onclick={() => onQuickAction(action)}
onclick={() => onQuickAction(option.prompt)}
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
>
{action}
{$t(option.key)}
</button>
{/each}
</div>
@@ -110,11 +158,31 @@
{:else}
<!-- Message list -->
<div class="flex flex-col gap-3">
{#each messages as message (message.id)}
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
{#each messages as message, messageIndex (message.id)}
{@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction}
{@const isLast = messageIndex === messages.length - 1}
{#if compaction}
<p class="ui:text-muted-foreground mx-1 px-2 text-[11px] leading-snug">
{$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })}
</p>
{/if}
<MessageBubble
{message}
{messages}
{courseId}
{isStreaming}
{isLast}
{onImplementPlan}
{onAskPlanChanges}
{onSubmitTemplateAnswers}
{onSkipTemplateForm}
{onMentionClick}
/>
{/each}
{#if status === 'submitted' && isStreaming}
{#if isStreaming && (isStudent || !planExecutionState)}
<div class="flex items-start">
<div class="ui:bg-muted rounded-lg px-3 py-2">
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
@@ -122,12 +190,14 @@
</div>
{/if}
{#if planExecutionState}
{#if planExecutionState && !isStudent}
<div class="mt-2">
<ProgressCard
title={planExecutionState.title}
titleKey={planExecutionState.titleKey}
steps={planExecutionState.steps}
currentAction={planExecutionState.currentAction}
currentActionLine={planExecutionState.currentActionLine}
{courseId}
onNavigate={onMentionClick}
onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped}
/>
@@ -0,0 +1,66 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
import Minimize2Icon from '@lucide/svelte/icons/minimize-2';
import LoaderIcon from '@lucide/svelte/icons/loader';
import { t } from '$lib/utils/functions/translations';
interface Props {
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
onCompactConversation: () => void;
onStartNewChat: () => void;
}
let { contextFullBusy, compactConversationDisabled = false, onCompactConversation, onStartNewChat }: Props = $props();
const compactBusy = $derived(contextFullBusy === 'compact');
const busy = $derived(contextFullBusy !== null);
</script>
<div class="border-t px-3 py-4">
<div
class="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-5 text-center dark:border-amber-800 dark:bg-amber-950"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{$t('ai_assistant.context_full_title')}
</p>
<p class="ui:text-muted-foreground text-xs">
{$t('ai_assistant.context_full_description')}
</p>
<div class="flex w-full max-w-xs flex-col gap-2 pt-1 sm:max-w-sm">
<Button
variant="default"
size="sm"
onclick={onCompactConversation}
disabled={busy || compactConversationDisabled}
class="w-full gap-2"
>
{#if compactBusy}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_compacting')}
{:else}
<Minimize2Icon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_compact')}
{/if}
</Button>
<Button
variant="outline"
size="sm"
onclick={onStartNewChat}
disabled={busy}
class="w-full gap-2 border-amber-300 bg-amber-50/70 text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100 dark:hover:bg-amber-900"
>
{#if contextFullBusy === 'new_chat'}
<LoaderIcon size={14} class="shrink-0 animate-spin" />
{$t('ai_assistant.context_full_summarizing')}
{:else}
<MessageSquarePlusIcon size={14} class="shrink-0" />
{$t('ai_assistant.context_full_new_chat')}
{/if}
</Button>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More