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* 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 storybook-static
.svelte-kit/ .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. 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 ## Naming Convention
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`). - 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); 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 ### Step 6: Build
```bash ```bash
pnpm --filter @cio/api build pnpm --filter @cio/api build
+26
View File
@@ -9,12 +9,20 @@ BETTER_AUTH_SECRET=""
AUTH_BEARER_TOKEN="" AUTH_BEARER_TOKEN=""
AUTH_COOKIE_DOMAIN="" 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_HOST=""
SMTP_PORT="" SMTP_PORT=""
SMTP_USER="" SMTP_USER=""
SMTP_SENDER="" SMTP_SENDER=""
SMTP_PASSWORD="" 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) # 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 # When API runs in Docker: use http://minio:9000. For local API: use http://localhost:9000
MINIO_ROOT_USER="" MINIO_ROOT_USER=""
@@ -34,6 +42,19 @@ CLOUDFLARE_BUCKET_DOMAIN=""
CLOUDFLARE_SECRET_ACCESS_KEY="" CLOUDFLARE_SECRET_ACCESS_KEY=""
SENTRY_DNS="" 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) # Enterprise (Optional)
# Required only when using Enterprise-only features (SSO, token-auth, no-tracking) # Required only when using Enterprise-only features (SSO, token-auth, no-tracking)
LICENSE_KEY="" LICENSE_KEY=""
@@ -51,6 +72,11 @@ GOOGLE_API_KEY=""
ANTHROPIC_API_KEY="" ANTHROPIC_API_KEY=""
MOONSHOT_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 event tracking for AI observability (optional — silently skipped when not set)
# TINYBIRD_TOKEN="" # TINYBIRD_TOKEN=""
# TINYBIRD_BASE_URL="https://api.tinybird.co" # TINYBIRD_BASE_URL="https://api.tinybird.co"
+1
View File
@@ -1 +1,2 @@
static static
dist
+7 -2
View File
@@ -12,7 +12,7 @@
"build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build", "build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build",
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"", "dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
"dev:server": "tsx watch src/index.ts", "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", "deploy": "wrangler deploy",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest NODE_ENV=test", "test": "vitest NODE_ENV=test",
@@ -40,9 +40,14 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.388.0", "@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.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/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/certificates": "workspace:*",
"@cio/db": "workspace:*", "@cio/db": "workspace:*",
"@cio/email": "workspace:*", "@cio/email": "workspace:*",
"@cio/jobs": "workspace:*",
"@cio/question-types": "workspace:*", "@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*", "@cio/utils": "workspace:*",
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.7",
@@ -76,7 +81,7 @@
"@typescript-eslint/parser": "7.0.1", "@typescript-eslint/parser": "7.0.1",
"@vitest/coverage-v8": "^1.2.2", "@vitest/coverage-v8": "^1.2.2",
"eslint": "^9.31.0", "eslint": "^9.31.0",
"prettier": "^3.2.5", "prettier": "3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
"tsx": "^4.7.1", "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'; import { logger } from 'hono/logger';
// ROUTES // ROUTES
import { mailRouter } from '@api/routes/mail'; import { mailRouter } from '@api/routes/mail';
import { jobsRouter } from '@api/routes/jobs';
import { mediaRouter } from '@api/routes/media'; import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding'; import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization'; import { organizationRouter } from '@api/routes/organization';
@@ -31,7 +32,8 @@ import { signupGuard } from '@api/middlewares/signup-guard';
import { programRouter } from '@api/routes/program'; import { programRouter } from '@api/routes/program';
import { unsplashRouter } from '@api/routes/unsplash/unsplash'; import { unsplashRouter } from '@api/routes/unsplash/unsplash';
import { agentRouter } from '@api/routes/agent'; 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 // Create Hono app with chaining for RPC support
export const app = new Hono() export const app = new Hono()
@@ -43,7 +45,7 @@ export const app = new Hono()
'*', '*',
cors({ cors({
origin: TRUSTED_ORIGINS, origin: TRUSTED_ORIGINS,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'], allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
maxAge: 600, maxAge: 600,
@@ -94,6 +96,7 @@ export const app = new Hono()
.route('/domain', domainRouter) .route('/domain', domainRouter)
.route('/mail', mailRouter) .route('/mail', mailRouter)
.route('/media', mediaRouter) .route('/media', mediaRouter)
.route('/jobs', jobsRouter)
.route('/license', licenseRouter) .route('/license', licenseRouter)
.route('/organization', organizationRouter) .route('/organization', organizationRouter)
.route('/organization/sso', organizationSsoRouter) .route('/organization/sso', organizationSsoRouter)
@@ -107,7 +110,7 @@ export const app = new Hono()
.route('/program', programRouter) .route('/program', programRouter)
.route('/unsplash', unsplashRouter) .route('/unsplash', unsplashRouter)
.route('/widgets', publicWidgetsRouter) .route('/widgets', publicWidgetsRouter)
.route('/internal/compliance', internalComplianceRouter) .route('/internal', internalRouter)
.route('/agent', agentRouter) .route('/agent', agentRouter)
// Error handling // Error handling
@@ -115,3 +118,7 @@ export const app = new Hono()
console.error('Error:', err); console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500); 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 for enterprise features (SSO, token-auth, no-tracking, etc.) */
LICENSE_KEY: z.string().optional(), 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 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); export const env = envSchema.parse(process.env);
+1 -1
View File
@@ -1,5 +1,5 @@
// API-specific constants (file size limits) // 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_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
export const MAX_IMAGE_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 { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account'; import { ZUpdateProfile } from '@cio/utils/validation/account';
import { accountWorkspacesRouter } from '@api/routes/account/workspaces';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono() export const accountRouter = new Hono()
.route('/', accountWorkspacesRouter)
.get('/', authMiddleware, async (c) => { .get('/', authMiddleware, async (c) => {
const user = c.get('user')!; 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, ZAgentCreditPurchase,
ZAgentCreditsBody, ZAgentCreditsBody,
ZAgentGenerateCourseTitleBody, ZAgentGenerateCourseTitleBody,
ZAgentStatusQuery ZAgentGenerateTextBody,
ZAgentStatusQuery,
ZAgentSummarizeBody,
ZTutorUsageQuery,
ZTutorUsagePeriod,
ZTutorUsageUserParam
} from '@cio/utils/validation/agent'; } from '@cio/utils/validation/agent';
import { import {
addCredits, addCredits,
@@ -23,15 +28,32 @@ import {
isOrgOnPaidPlan, isOrgOnPaidPlan,
recordTokenUsage recordTokenUsage
} from '@api/services/agent/usage'; } 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 { parseAndStoreDocument } from '@api/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase'; import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation'; import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-generation';
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group'; import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getChatConversation } from '@cio/db/queries/agent'; 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 { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models'; 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 { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis'; import { redis } from '@api/utils/redis/redis';
import { db } from '@cio/db'; import { db } from '@cio/db';
@@ -41,14 +63,17 @@ import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@api/services/lesson/lesson'; import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise'; import { getExercise } from '@api/services/exercise/exercise';
import { trimMessageHistory } from '@api/services/agent/context-window'; import { trimMessageHistory } from '@api/services/agent/context-window';
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import { import {
collectDocumentIds, collectDocumentIds,
getActiveCourseTemplateId,
getLatestImplementationPlan, getLatestImplementationPlan,
loadDocumentsText, loadDocumentsText,
verifyExerciseBelongsToCourse, verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context'; } from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools'; import { buildAgentTools } from '@api/services/agent/chat-tools';
import { summarizeConversation } from '@api/services/agent/summarize';
import { agentHistoryRouter } from './history'; import { agentHistoryRouter } from './history';
const agentCoreRouter = new Hono() const agentCoreRouter = new Hono()
@@ -63,7 +88,8 @@ const agentCoreRouter = new Hono()
const status: AgentStatus = { const status: AgentStatus = {
enabled: false, enabled: false,
role: AgentRole.STUDENT, 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 }); return c.json({ success: true, data: status });
@@ -74,10 +100,16 @@ const agentCoreRouter = new Hono()
const usage = await getTokenBalance(orgId); 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 = { const status: AgentStatus = {
enabled: true, enabled: true,
role, role,
usage usage,
tutor
}; };
return c.json({ success: true, data: status }); return c.json({ success: true, data: status });
@@ -187,6 +219,67 @@ const agentCoreRouter = new Hono()
return handleError(c, error, 'Failed to fetch usage stats'); 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) => { .post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
try { try {
const orgId = c.req.header('cio-org-id')!; 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) => { .post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
const user = c.get('user')!; const user = c.get('user')!;
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
@@ -240,7 +386,14 @@ const agentCoreRouter = new Hono()
try { try {
const { courseId, messages, context, model: requestedModel } = c.req.valid('json'); 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 modelDescriptor = AGENT_MODELS[modelId];
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider); const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
@@ -250,13 +403,6 @@ const agentCoreRouter = new Hono()
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId }; 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 const [courseRow] = await db
.select({ .select({
title: schema.course.title, 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); 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); const isPaid = await isOrgOnPaidPlan(orgId);
if (!isPaid) { 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); await enforceTokenBalance(orgId);
}
const documentIds = collectDocumentIds(messages, context?.documentId); const documentIds = collectDocumentIds(messages, context?.documentId);
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined; const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
@@ -348,38 +501,98 @@ const agentCoreRouter = new Hono()
const startTime = Date.now(); const startTime = Date.now();
const model = createModel(providerConfig); const model = createModel(providerConfig);
const approvedPlan = getLatestImplementationPlan(messages); const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined;
const systemPrompt = [ const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined;
buildSystemPrompt(agentContext), 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 approvedPlan
? [ });
'',
'The latest user message approved a final course plan for immediate execution.', const agentTools =
'Implement that exact plan directly without asking the user to restate it.', role === AgentRole.STUDENT
'Treat this approved plan as the canonical source if it differs from any earlier draft.', ? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}` : buildAgentTools(orgId, user.id, courseId, messages);
].join('\n')
: ''
].join('\n');
const agentTools = buildAgentTools(orgId, user.id, courseId);
const trimmedMessages = trimMessageHistory(messages); const trimmedMessages = trimMessageHistory(messages);
const modelMessages = await convertToModelMessages(trimmedMessages); const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
let completedStepCount = 0; let completedStepCount = 0;
let finishReason: string | undefined; let finishReason: string | undefined;
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC; 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 const systemContent = isAnthropic
? { ? {
role: 'system' as const, role: 'system' as const,
content: systemPrompt, content: systemPrompt,
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } } providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } }
}
} }
: systemPrompt; : 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({ const result = streamText({
model, model,
maxRetries: 0, maxRetries: 2,
system: systemContent, system: systemContent,
messages: modelMessages, messages: modelMessages,
tools: agentTools, tools: agentTools,
@@ -394,6 +607,21 @@ const agentCoreRouter = new Hono()
const inputTokens = totalUsage?.inputTokens ?? 0; const inputTokens = totalUsage?.inputTokens ?? 0;
const outputTokens = totalUsage?.outputTokens ?? 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) { if (totalUsage) {
await recordTokenUsage( await recordTokenUsage(
orgId, orgId,
@@ -408,6 +636,10 @@ const agentCoreRouter = new Hono()
); );
} }
if (role === AgentRole.STUDENT) {
await incrementStudentTutorCount(orgId, user.id, courseId);
}
trackAgentEvent(AgentEvent.CHAT_COMPLETED, { trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
orgId, orgId,
userId: user.id, userId: user.id,
+55
View File
@@ -7,6 +7,7 @@ import {
ZAgentConversationParam, ZAgentConversationParam,
ZAgentConversationCreateBody, ZAgentConversationCreateBody,
ZAgentHistorySaveBody, ZAgentHistorySaveBody,
ZAgentHistoryRenameBody,
ZAgentHistoryDeleteParam, ZAgentHistoryDeleteParam,
ZAgentGenerateTitleParam, ZAgentGenerateTitleParam,
ZAgentGenerateTitleBody ZAgentGenerateTitleBody
@@ -20,6 +21,7 @@ import {
deleteChatConversation, deleteChatConversation,
updateConversationTitle updateConversationTitle
} from '@api/services/agent/chat-history'; } from '@api/services/agent/chat-history';
import { compactConversation } from '@api/services/agent/compact';
import { generateConversationTitle } from '@api/services/agent/title-generation'; import { generateConversationTitle } from '@api/services/agent/title-generation';
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers'; 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 /agent/history/:conversationId
* Delete a conversation. * 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 * POST /agent/history/:conversationId/generate-title
* Generate a short title from the first user message using a cheap model. * 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 { import {
ZCertificateDownload, ZCertificateDownloadRequest,
ZCourseClone, ZCourseClone,
ZCourseCloneParam, ZCourseCloneParam,
ZCourseCreate, ZCourseCreate,
@@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { attendanceRouter } from '@api/routes/course/attendance'; import { attendanceRouter } from '@api/routes/course/attendance';
import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone'; 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 { enrollInCourse } from '@api/services/course/invite';
import { exerciseRouter } from '@api/routes/course/exercise'; import { exerciseRouter } from '@api/routes/course/exercise';
import { extractClientIp } from '@api/utils/redis/key-generators'; 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 { 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 { invitesRouter } from '@api/routes/course/invite';
import { katexRouter } from '@api/routes/course/katex'; import { katexRouter } from '@api/routes/course/katex';
import { lessonRouter } from '@api/routes/course/lesson'; 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 { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { zValidator } from '@hono/zod-validator'; 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({ const enrollRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000, windowMs: 60 * 60 * 1000,
maxRequests: 20, maxRequests: 20,
@@ -454,27 +487,67 @@ export const courseRouter = new Hono()
authMiddleware, authMiddleware,
courseMemberMiddleware, courseMemberMiddleware,
zValidator('param', ZCourseDownloadParam), zValidator('param', ZCourseDownloadParam),
zValidator('json', ZCertificateDownload), zValidator('json', ZCertificateDownloadRequest),
async (c) => { async (c) => {
try {
const { courseId } = c.req.valid('param'); const { courseId } = c.req.valid('param');
const user = c.get('user')!; const user = c.get('user')!;
await assertCertificateDownloadAllowed(courseId, user.id); const body = c.req.valid('json');
const validatedData = c.req.valid('json'); const input = await loadCertificateInput(courseId, user.id, body);
const buffer = await generateCertificatePdf(input);
const result = await generateCertificate(validatedData);
c.header('Content-Type', 'application/pdf'); 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( return c.body(
new ReadableStream({ new ReadableStream({
start(controller) { start(controller) {
controller.enqueue(result); controller.enqueue(buffer);
controller.close(); 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( .post(
@@ -542,4 +615,5 @@ export const courseRouter = new Hono()
.route('/:courseId/newsfeed', newsfeedRouter) .route('/:courseId/newsfeed', newsfeedRouter)
.route('/:courseId/members', membersRouter) .route('/:courseId/members', membersRouter)
.route('/:courseId/invites', invitesRouter) .route('/:courseId/invites', invitesRouter)
.route('/:courseId/ai-tutor', courseAiTutorRouter)
.route('/presign', presignRouter); .route('/presign', presignRouter);
+99 -2
View File
@@ -1,13 +1,44 @@
import { Hono } from '@api/utils/hono'; 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 { authMiddleware } from '@api/middlewares/auth';
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash'; import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
import { getOrgComplianceOverview } from '@api/services/course/compliance';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import {
getCountryBreakdown,
getCourseFunnel,
getLandingStats,
getPopularTypes,
ingestEventBatch
} from '@api/services/analytics';
export const dashAnalyticsRouter = new Hono() 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) => { .get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
try { try {
const { orgId, siteName } = c.req.valid('query'); const { orgId, siteName } = c.req.valid('query');
@@ -39,4 +70,70 @@ export const dashAnalyticsRouter = new Hono()
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to load login streak'); 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 { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key'; import { apiKeyMiddleware } from '@api/middlewares/api-key';
@@ -34,4 +35,32 @@ export const internalComplianceRouter = new Hono()
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to run compliance reminder scan'); 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 { Hono } from '@api/utils/hono';
import { ZDeliverEmail } from '@cio/utils/validation/mail'; import { ZDeliverEmail } from '@cio/utils/validation/mail';
import { apiKeyMiddleware } from '@api/middlewares/api-key'; 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 { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator'; 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) => { export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
const validatedData = c.req.valid('json'); const validatedData = c.req.valid('json');
try { 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( return c.json(
{ {
success: false, success: true,
error: 'Some emails failed to send', data: {
code: 'EMAIL_SEND_PARTIAL_FAILURE', accepted: jobIds.length,
details: results jobIds
},
500
);
} }
},
return c.json({ success: true, data: results }, 200); 202
);
} catch (error) { } 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, getAssetService,
getAssetUsageGraphService, getAssetUsageGraphService,
getOrganizationAssetStorageService, getOrganizationAssetStorageService,
getTranscriptForOrganizationAssetService,
getYouTubeMetadataService, getYouTubeMetadataService,
listOrganizationAssetsService, listOrganizationAssetsService,
updateAssetService 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 /organization/assets/:assetId
* Get a single asset by id * Get a single asset by id
@@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { TOrganization } from '@db/types'; import { TOrganization } from '@db/types';
import { assetsRouter } from '@api/routes/organization/assets'; import { assetsRouter } from '@api/routes/organization/assets';
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
@@ -670,4 +671,5 @@ export const organizationRouter = new Hono()
.route('/tags', tagsRouter) .route('/tags', tagsRouter)
.route('/widgets', widgetsRouter) .route('/widgets', widgetsRouter)
.route('/assets', assetsRouter) .route('/assets', assetsRouter)
.route('/ai-tutor', organizationAiTutorRouter)
.route('/:orgId/quiz', quizRouter); .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 { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { import { ZCreateWidget, ZRollbackWidget, ZUpdateWidget, ZWidgetIdParams } from '@cio/utils/validation/widget';
ZCreateWidget,
ZPreviewWidget,
ZRollbackWidget,
ZUpdateWidget,
ZWidgetIdParams
} from '@cio/utils/validation/widget';
import { import {
createOrganizationWidget, createOrganizationWidget,
deleteOrganizationWidget, deleteOrganizationWidget,
getOrganizationWidgetDetail, getOrganizationWidgetDetail,
listOrganizationWidgets, listOrganizationWidgets,
previewOrganizationWidget,
publishOrganizationWidget, publishOrganizationWidget,
rollbackOrganizationWidget, rollbackOrganizationWidget,
updateOrganizationWidget updateOrganizationWidget
@@ -87,25 +80,6 @@ export const widgetsRouter = new Hono()
return handleError(c, error, 'Failed to delete widget'); 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) => { .post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
try { try {
const orgId = c.req.header('cio-org-id')!; const orgId = c.req.header('cio-org-id')!;
+255
View File
@@ -3,10 +3,14 @@ import * as z from 'zod';
import { import {
ZAddCourseToProgram, ZAddCourseToProgram,
ZAddProgramMembers, ZAddProgramMembers,
ZAssignExistingStudentsToProgram,
ZCreateProgram, ZCreateProgram,
ZCreateProgramGoal,
ZCreateProgramNewsfeed, ZCreateProgramNewsfeed,
ZCreateProgramNewsfeedComment, ZCreateProgramNewsfeedComment,
ZInviteStudentsToProgram,
ZUpdateProgram, ZUpdateProgram,
ZUpdateProgramGoal,
ZUpdateProgramMember, ZUpdateProgramMember,
ZUpdateProgramNewsfeed, ZUpdateProgramNewsfeed,
ZUpdateProgramReaction ZUpdateProgramReaction
@@ -34,9 +38,23 @@ import {
updateProgramNewsfeedReactionService, updateProgramNewsfeedReactionService,
updateProgramNewsfeedService updateProgramNewsfeedService
} from '@api/services/program/program'; } 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 { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
import { programMemberMiddleware } from '@api/middlewares/program-member'; import { programMemberMiddleware } from '@api/middlewares/program-member';
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member'; import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team'; import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
@@ -52,6 +70,7 @@ const ZCommentParam = z.object({
feedId: z.string().uuid(), feedId: z.string().uuid(),
commentId: z.coerce.number().int() commentId: z.coerce.number().int()
}); });
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
const ZListQuery = z.object({ const ZListQuery = z.object({
cursor: z.string().optional(), cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(50).default(10) 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 ─────────────────────────────────────────────────────────────── // ── Courses ───────────────────────────────────────────────────────────────
/** /**
@@ -484,4 +553,190 @@ export const programRouter = new Hono()
return handleError(c, error, 'Failed to delete program newsfeed comment'); 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 { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant'; 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 { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course'; import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
@@ -56,7 +62,14 @@ export const updateContentParam = z.object({
export const questionSchema = z.object({ export const questionSchema = z.object({
question: z.string().min(1), 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), points: z.number().min(0).default(1),
order: z.number().int().min(0), order: z.number().int().min(0),
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() })) 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) 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({ export const updateOptionSchema = z.object({
id: z.number().int().optional(), id: z.number().int().optional(),
label: z.string().min(1).optional(), label: z.string().min(1).optional(),
isCorrect: z.boolean().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(), id: z.number().int(),
question: z.string().min(1).optional(), 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(), points: z.number().min(0).optional(),
order: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(),
exerciseSectionId: z exerciseSectionId: z
@@ -171,9 +203,22 @@ export const updateQuestionPatchSchema = z.object({
.describe( .describe(
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.' '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() 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({ export const updateQuestionsParam = z.object({
exerciseId: z.string(), exerciseId: z.string(),
@@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({
}); });
export const coursePlanParam = z.object({ plan: CoursePlanSchema }); 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 // 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. // `z.literal` values must be relaxed to plain types in the schemas the model sees.
export const agentLessonTabsOrder = z.array( export const agentLessonTabsOrder = z.array(
@@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array(
}) })
); );
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({ 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({ 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() metadata: agentLandingPageMetadataUpdate.optional()
}).refine( }).refine(
(data) => (data) =>
+26 -3
View File
@@ -3,7 +3,7 @@ import { getDocumentText } from '@api/services/agent/document';
import { redis } from '@api/utils/redis/redis'; import { redis } from '@api/utils/redis/redis';
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent'; import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
import { z } from 'zod'; import { z } from 'zod';
import { CoursePlanSchema } from '@cio/ai-assistant'; import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
type ResourceOwnershipRow = { type ResourceOwnershipRow = {
courseId: string | null; 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--) { for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PlanMetadataMessage; const message = messages[index] as PlanMetadataMessage;
@@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
continue; continue;
} }
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload); const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
if (parsedPlan.success) { if (parsedPlan.success) {
return parsedPlan.data; return parsedPlan.data;
@@ -158,3 +158,26 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
return undefined; 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, listChatConversations,
getChatConversation, getChatConversation,
createChatConversation, createChatConversation,
saveChatMessages, saveChatMessages as saveDbChatMessages,
deleteChatConversation, deleteChatConversation,
updateConversationTitle, updateConversationTitle,
getChatHistory, getChatHistory,
saveChatHistory, saveChatHistory as saveDbChatHistory,
deleteChatHistory deleteChatHistory
} from '@cio/db/queries/agent'; } 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 { export {
listChatConversations, listChatConversations,
getChatConversation, getChatConversation,
createChatConversation, createChatConversation,
saveChatMessages,
deleteChatConversation, deleteChatConversation,
updateConversationTitle, updateConversationTitle,
// Legacy compat // Legacy compat
getChatHistory as fetchChatHistory, getChatHistory as fetchChatHistory,
saveChatHistory as persistChatHistory, persistChatHistory,
deleteChatHistory as clearChatHistory deleteChatHistory as clearChatHistory
}; };
+53 -4
View File
@@ -28,6 +28,7 @@ import {
} from '@api/services/agent/chat-context'; } from '@api/services/agent/chat-context';
import { import {
addQuestionsParam, addQuestionsParam,
askTemplateQuestionsParam,
coursePlanParam, coursePlanParam,
createExerciseParam, createExerciseParam,
createExerciseSectionParam, createExerciseSectionParam,
@@ -35,6 +36,7 @@ import {
createSectionParam, createSectionParam,
emptyParam, emptyParam,
exerciseReadParam, exerciseReadParam,
fetchDocumentationUrlParam,
goLiveParam, goLiveParam,
lessonReadParam, lessonReadParam,
reorderContentParam, reorderContentParam,
@@ -46,6 +48,7 @@ import {
updateQuestionsParam, updateQuestionsParam,
updateSectionParam updateSectionParam
} from '@api/services/agent/agent-tool-schemas'; } from '@api/services/agent/agent-tool-schemas';
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown { function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
if (value == null) return value; 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 { return {
get_course_structure: tool({ get_course_structure: tool({
description: description:
@@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
}); });
return { return {
lessonId: args.lessonId, lessonId: args.lessonId,
lessonTitle: lesson.title,
locale: args.locale, locale: args.locale,
contentLength: normalizedContent.length, contentLength: normalizedContent.length,
updated: true updated: true
@@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
]; ];
await updateExerciseService(args.exerciseId, { questions: allQuestions }); 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 }); 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({ update_course_landing_page: tool({
description: 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, inputSchema: updateCourseLandingPageParam,
execute: async (args) => { execute: async (args) => {
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => { 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; 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> = { const TITLE_MODELS: Record<AIProvider, string> = {
[AIProvider.OPENAI]: 'gpt-4o-mini', [AIProvider.OPENAI]: 'gpt-4o-mini',
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001', [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' [AIProvider.MOONSHOT]: 'kimi-k2.6'
}; };
@@ -55,7 +55,7 @@ export async function generateConversationTitle(
const truncatedMessage = firstMessageText.slice(0, 1000); 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( const { text } = await generateText(
providerConfig.provider === AIProvider.GOOGLE 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 ─────────────────────────────────────────────────── // ─── 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> = { const MODEL_COST_MULTIPLIER: Record<string, number> = {
'gemini-2.5-flash': 1, 'gemini-3.1-flash-lite': 1,
'gpt-5.4-mini': 3, 'gemini-2.5-flash': 1.5,
'gpt-5.4-mini': 4,
'claude-sonnet-4-6': 11, 'claude-sonnet-4-6': 11,
'claude-haiku-4-5-20251001': 1, 'claude-haiku-4-5-20251001': 1.5,
'kimi-k2.6': 3 'kimi-k2.6': 4
}; };
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number { 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, TAssetUpdate,
TYouTubeMetadataQuery TYouTubeMetadataQuery
} from '@cio/utils/validation/assets'; } from '@cio/utils/validation/assets';
import type { TTranscriptResponse } from '@cio/utils/validation/media';
import { import {
countAssetUsagesByAsset, countAssetUsagesByAsset,
createAssetUsage, createAssetUsage,
@@ -22,6 +23,9 @@ import {
listAssetUsagesByAsset, listAssetUsagesByAsset,
updateAsset updateAsset
} from '@cio/db/queries/assets'; } 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}$/; 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) { export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
try { try {
return await createOrGetAssetByStorageKey({ const asset = await createOrGetAssetByStorageKey({
organizationId: orgId, organizationId: orgId,
kind: data.kind, kind: data.kind,
provider: data.provider, provider: data.provider,
@@ -230,6 +234,19 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
metadata: data.metadata ?? {}, metadata: data.metadata ?? {},
createdByProfileId: profileId 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) { } catch (error) {
throw new AppError( throw new AppError(
error instanceof Error ? error.message : 'Failed to create asset', 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) { export async function getAssetService(orgId: string, assetId: string) {
try { try {
const asset = await getAssetById(assetId, orgId); 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) { export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
const videoId = extractYouTubeVideoId(query.url); const videoId = extractYouTubeVideoId(query.url);
if (!videoId) { 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 type TCourseCertificationRow
} from '@cio/db/queries/course/course'; } from '@cio/db/queries/course/course';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
export type CertificationBlocker = { export type CertificationBlocker = {
code: code:
@@ -240,6 +241,22 @@ export async function evaluateCourseCertification(
courseRow, courseRow,
earnedAt 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 }; return { ...evaluation, certificateEarnedAt, isNewCompletion };
+108 -1
View File
@@ -6,11 +6,13 @@ import {
getCourseComplianceHistoryRows, getCourseComplianceHistoryRows,
getCourseCurrentComplianceRows, getCourseCurrentComplianceRows,
getLatestComplianceRecordsByProfiles, getLatestComplianceRecordsByProfiles,
getOrgComplianceLearnerRows,
getStudentCourseMembersForCompliance, getStudentCourseMembersForCompliance,
listComplianceRecordsReadyForExpiry, listComplianceRecordsReadyForExpiry,
listLatestComplianceRecordsForReminderScan, listLatestComplianceRecordsForReminderScan,
updateCourseCertificateIssueStatusByRecordId, updateCourseCertificateIssueStatusByRecordId,
updateCourseCompletionRecord updateCourseCompletionRecord,
type OrgComplianceLearnerRow
} from '@cio/db/queries/course/compliance'; } from '@cio/db/queries/course/compliance';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { getCourseById } from '@cio/db/queries/course'; import { getCourseById } from '@cio/db/queries/course';
@@ -712,3 +714,108 @@ export async function runComplianceReminderScan() {
reminderEventCount 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 { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth'; 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 { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED'; type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM'; type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: {
}) { }) {
try { try {
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined); const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
await sendEmail('studentCourseWelcome', { await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail, to: input.studentEmail,
fields: { fields: {
orgName: input.orgName, orgName: input.orgName,
courseName: input.courseName, courseName: input.courseName,
loginUrl loginUrl
}, },
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`) from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
}); });
} catch (error) { } catch (error) {
console.error('Failed to send student welcome email:', error); console.error('Failed to enqueue student welcome email:', error);
} }
try { try {
@@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: {
const studentProfile = await getProfileById(input.studentId); const studentProfile = await getProfileById(input.studentId);
const studentName = studentProfile?.fullname || input.studentEmail; const studentName = studentProfile?.fullname || input.studentEmail;
await Promise.all( const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email);
teachers
.map((teacher) => teacher.email) if (teacherEmails.length === 0) return;
.filter((email): email is string => !!email)
.map((teacherEmail) => await enqueueTransactionalEmail('teacherStudentJoined', {
sendEmail('teacherStudentJoined', { to: teacherEmails,
to: teacherEmail,
fields: { fields: {
courseName: input.courseName, courseName: input.courseName,
studentName, studentName,
studentEmail: input.studentEmail studentEmail: input.studentEmail
}, },
from: buildEmailFromName('ClassroomIO') from: buildEmailFromName('ClassroomIO'),
}).catch((error) => { idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error); });
})
)
);
} catch (error) { } 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 { try {
const responses = await sendEmail('studentCourseInvite', { await enqueueTransactionalEmail('studentCourseInvite', {
to: input.recipientEmail, to: input.recipientEmail,
fields: { fields: {
orgName: input.orgName, orgName: input.orgName,
@@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: {
inviteLink: createdInvite.inviteLink, inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt) 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); // Optimistic EMAIL_SENT — see comment in services/organization/invite.ts.
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
};
}
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', { await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
actorProfileId: input.createdByProfileId, actorProfileId: input.createdByProfileId,
targetEmail: input.recipientEmail targetEmail: input.recipientEmail
@@ -683,6 +664,14 @@ export async function enrollInCourse(
studentEmail: normalizedEmail studentEmail: normalizedEmail
}); });
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
orgId: org.id,
userId: user.id,
courseId,
props: { path: 'free-enrollment' }
});
return { return {
success: true, success: true,
alreadyJoined: false, alreadyJoined: false,
@@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
studentId: user.id, studentId: user.id,
studentEmail: normalizedEmail studentEmail: normalizedEmail
}); });
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
userId: user.id,
courseId: result.courseId,
props: { path: 'invite' }
});
} }
return { 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 { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course'; 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 { export interface PaymentRequestData {
courseId: string; courseId: string;
@@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email; const teacherEmail = teacherResult[0].email;
// Send email to teacher about student buy request
try { try {
await sendEmail('teacherStudentBuyRequest', { await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail, to: teacherEmail,
fields: { fields: {
courseName, courseName,
studentEmail: data.studentEmail, studentEmail: data.studentEmail,
studentFullname: data.studentFullname studentFullname: data.studentFullname
}, },
from: buildEmailFromName('ClassroomIO') from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
}); });
} catch (emailError) { } catch (emailError) {
console.error('Failed to send teacher buy request email:', emailError); console.error('Failed to enqueue teacher buy request email:', emailError);
// Don't fail the request if email fails
} }
// Send email to student with payment instructions
try { try {
await sendEmail('studentProvePayment', { await enqueueTransactionalEmail('studentProvePayment', {
to: data.studentEmail, to: data.studentEmail,
fields: { fields: {
courseName, courseName,
@@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) {
orgName orgName
}, },
from: buildEmailFromName(`${orgName} - ClassroomIO`), from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail replyTo: teacherEmail,
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
}); });
} catch (emailError) { } catch (emailError) {
console.error('Failed to send student payment proof email:', emailError); console.error('Failed to enqueue student payment proof email:', emailError);
// Don't fail the request if email fails
} }
trackServerEvent({
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
courseId: data.courseId,
props: { path: 'payment-request', orgName }
});
return { return {
success: true, success: true,
courseName, courseName,
+22 -24
View File
@@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth'; 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'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
/** /**
@@ -85,45 +86,44 @@ export async function addMember(
studentName = data.name || null; studentName = data.name || null;
} }
// Send welcome email to student
if (studentEmail) { if (studentEmail) {
try { try {
await sendEmail('studentCourseWelcome', { await enqueueTransactionalEmail('studentCourseWelcome', {
to: studentEmail, to: studentEmail,
fields: { fields: {
orgName, orgName,
courseName, courseName,
loginUrl loginUrl
}, },
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`) from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
}); });
} catch (emailError) { } 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 }); const teachersResult = await getCourseTeachers({ courseId });
// Send notification to all teachers
if (teachersResult.length > 0 && studentEmail && studentName) { if (teachersResult.length > 0 && studentEmail && studentName) {
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null); const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
await Promise.all( if (teacherEmails.length > 0) {
teacherEmails.map((teacherEmail) => try {
sendEmail('teacherStudentJoined', { await enqueueTransactionalEmail('teacherStudentJoined', {
to: teacherEmail, to: teacherEmails,
fields: { fields: {
courseName, courseName,
studentName, studentName,
studentEmail studentEmail
}, },
from: buildEmailFromName('ClassroomIO') from: buildEmailFromName('ClassroomIO'),
}).catch((emailError) => { idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError); });
}) } catch (emailError) {
) console.error('Failed to enqueue teacher notification email:', emailError);
); }
}
} }
} }
} catch (emailError) { } catch (emailError) {
@@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const baseUrl = getDashboardBaseUrl(orgSiteName); const baseUrl = getDashboardBaseUrl(orgSiteName);
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`; const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
// Send welcome emails to teachers
const teacherEmailPromises: Promise<unknown>[] = []; const teacherEmailPromises: Promise<unknown>[] = [];
addedMembers.forEach((member, index) => { addedMembers.forEach((member, index) => {
const memberData = members[index]; const memberData = members[index];
// Only send emails to teachers (TUTOR or ADMIN role) who have email
const roleId = memberData.roleId; const roleId = memberData.roleId;
const email = memberData.email; const email = memberData.email;
const name = memberData.name; const name = memberData.name;
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) { if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
teacherEmailPromises.push( teacherEmailPromises.push(
sendEmail('teacherCourseWelcome', { enqueueTransactionalEmail('teacherCourseWelcome', {
to: email, to: email,
fields: { fields: {
name, name,
@@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
courseName, courseName,
inviteLink inviteLink
}, },
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`) from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
}).catch((emailError) => { }).catch((emailError) => {
console.error(`Failed to send welcome email to ${email}:`, emailError); console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
return []; return [];
}) })
); );
@@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
try { try {
await Promise.all(teacherEmailPromises); await Promise.all(teacherEmailPromises);
} catch (emailError) { } catch (emailError) {
// Log but don't fail the request if email fails console.error('Failed to enqueue welcome emails:', emailError);
console.error('Failed to send welcome emails:', emailError);
} }
} }
+72 -3
View File
@@ -1,4 +1,12 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; 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 { import {
getCourseRowBySlug, getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery, getPublicCourseItem as getPublicCourseItemQuery,
@@ -8,7 +16,6 @@ import {
} from '@cio/db/queries/course'; } from '@cio/db/queries/course';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise'; import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section'; 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. * 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 = export type PublicItemResponse =
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' }) | (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & { | (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
kind: 'exercise'; kind: 'exercise';
questions: PublicExercisePayload; questions: PublicExercisePayload;
@@ -74,7 +81,69 @@ export async function getPublicCourseItemService(
if (!baseItem) return null; if (!baseItem) return null;
if (baseItem.kind === 'lesson') { 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); 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'; } from '@cio/db/queries/newsfeed';
import { env } from '@api/config/env'; 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 * Lists newsfeed items for a course
@@ -359,12 +360,14 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgSiteName = feedData.organization?.siteName || 'app'; const orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`; const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
// Send email to all course members const recipients = feedData.courseMembers
const emailPromises = feedData.courseMembers.map((member) => { .map((member) => member.email)
if (!member.email) return Promise.resolve(); .filter((email): email is string => Boolean(email));
return sendEmail('newsfeedPost', { if (recipients.length === 0) return;
to: member.email,
await enqueueTransactionalEmail('newsfeedPost', {
to: recipients,
fields: { fields: {
courseTitle: feedData.courseTitle!, courseTitle: feedData.courseTitle!,
teacherName: feedData.author?.fullname || 'A teacher', teacherName: feedData.author?.fullname || 'A teacher',
@@ -373,11 +376,9 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
orgName orgName
}, },
from: buildEmailFromName(`${orgName} - ClassroomIO`), 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) { } catch (error) {
console.error('Error sending newsfeed post email:', error); console.error('Error sending newsfeed post email:', error);
// Don't throw - email failures shouldn't break the API // 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 orgSiteName = feedData.organization?.siteName || 'app';
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`; const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await sendEmail('newsfeedComment', { await enqueueTransactionalEmail('newsfeedComment', {
to: feedData.author.email, to: feedData.author.email,
fields: { fields: {
courseTitle: feedData.courseTitle, courseTitle: feedData.courseTitle,
+4 -3
View File
@@ -14,7 +14,7 @@ import { env } from '@api/config/env';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans'; import { PLAN } from '@cio/utils/plans';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { sendEmail } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs';
export async function createOrganizationWithOwner( export async function createOrganizationWithOwner(
profileId: string, 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); throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
} }
await sendEmail('welcome', { await enqueueTransactionalEmail('welcome', {
to: user.email, to: user.email,
fields: { fields: {
name: profile.fullname name: profile.fullname
} },
idempotencyKey: `onboarding:welcome:${user.id}`
}); });
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
+7 -1
View File
@@ -32,6 +32,7 @@ import {
getPublishedCoursesBySiteName getPublishedCoursesBySiteName
} from '@cio/db/queries/course'; } from '@cio/db/queries/course';
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag'; 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 { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types'; 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) { export async function createOrgPlan(data: TNewOrganizationPlan) {
try { 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({ const plan = await createOrganizationPlan({
orgId: data.orgId, orgId: targetOrgId,
planName: data.planName, planName: data.planName,
subscriptionId: data.subscriptionId, subscriptionId: data.subscriptionId,
triggeredBy: data.triggeredBy, triggeredBy: data.triggeredBy,
+23 -34
View File
@@ -5,7 +5,8 @@ import type {
TImportAudienceMembers TImportAudienceMembers
} from '@cio/utils/validation/organization'; } from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; 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 { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import { import {
createOrganizationInvite, createOrganizationInvite,
@@ -202,18 +203,19 @@ async function enrollAudienceStudentProfilesInCourses(
.map(async (p) => { .map(async (p) => {
const email = profileEmailMap.get(p.profileId)!; const email = profileEmailMap.get(p.profileId)!;
try { try {
await sendEmail('studentCourseWelcome', { await enqueueTransactionalEmail('studentCourseWelcome', {
to: email, to: email,
fields: { fields: {
orgName: organization.name, orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course', courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl loginUrl
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`
}); });
emailsSent++; emailsSent++;
} catch (emailError) { } 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)!; const email = profileEmailMap.get(pair.profileId)!;
try { try {
await sendEmail('studentProgramWelcome', { await enqueueTransactionalEmail('studentProgramWelcome', {
to: email, to: email,
fields: { fields: {
orgName: organization.name, orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program', programName: programNameById.get(pair.programId) || 'Program',
loginUrl loginUrl
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
}); });
emailsSent++; emailsSent++;
} catch (emailError) { } 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)!; const token = tokenByEmail.get(email)!;
try { try {
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', { await enqueueTransactionalEmail('studentOrgInvite', {
to: email, to: email,
fields: { fields: {
email, email,
@@ -394,27 +397,17 @@ async function createStudentOrgInvitesAndSendEmails(input: {
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel 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); // Optimistic — see comment in services/organization/invite.ts.
if (allSuccessful) {
return { return {
inviteId: invite.id, inviteId: invite.id,
email, email,
success: true as const, success: true as const,
error: undefined as string | undefined 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) { } catch (emailError) {
const message = emailError instanceof Error ? emailError.message : 'Unknown email error'; const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
return { inviteId: invite.id, email, success: false as const, error: message }; 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; let emailSent = false;
try { try {
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token);
const responses = await sendEmail('studentOrgInvite', { await enqueueTransactionalEmail('studentOrgInvite', {
to: emailToUse, to: emailToUse,
fields: { fields: {
email: emailToUse, email: emailToUse,
@@ -694,27 +687,23 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel 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([ await createOrganizationInviteAudits([
{ {
inviteId: invite.id, inviteId: invite.id,
organizationId: orgId, organizationId: orgId,
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED', eventType: 'EMAIL_SENT',
actorProfileId: invitedByProfileId, actorProfileId: invitedByProfileId,
targetEmail: emailToUse, targetEmail: emailToUse,
ipAddress: null, ipAddress: null,
userAgent: null, userAgent: null,
metadata: allSuccessful metadata: {}
? {}
: {
error: responses
.filter((r) => !r.success)
.map((r) => r.error || 'Unknown')
.join('; ')
}
} }
]); ]);
} catch (emailError) { } 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 { getDashboardBaseUrl } from '@api/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org'; import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { sendEmail } from '@cio/email'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -244,7 +244,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token);
try { try {
const responses = await sendEmail('inviteTeacher', { await enqueueTransactionalEmail('inviteTeacher', {
to: email, to: email,
fields: { fields: {
email, email,
@@ -253,25 +253,14 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
roleName, roleName,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
inviteLink inviteLink
} },
idempotencyKey: `org-invite-teacher:${invite.id}`
}); });
const allSuccessful = responses.every((response) => response.success); // Record EMAIL_SENT optimistically — the worker retries up to 5 times,
if (!allSuccessful) { // and a final failure flips the email_delivery row to `failed` for
const message = responses // operator follow-up. EMAIL_FAILED here only means the enqueue itself
.filter((response) => !response.success) // failed (DB outbox write or transient validation error).
.map((response) => response.error || 'Unknown email error')
.join('; ');
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', {
actorProfileId: invitedByProfileId,
targetEmail: email,
metadata: { error: message }
});
continue;
}
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', { await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
actorProfileId: invitedByProfileId, actorProfileId: invitedByProfileId,
targetEmail: email 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, TSubmissionGradesUpdate,
TSubmissionUpdate TSubmissionUpdate
} from '@cio/utils/validation/submission'; } from '@cio/utils/validation/submission';
import { buildEmailFromName, deliverEmail } from '@cio/email'; import { buildEmailFromName } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs';
import { import {
createSubmission, createSubmission,
deleteSubmission, deleteSubmission,
@@ -977,14 +978,13 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
`; `;
} }
await deliverEmail([ await enqueueRawEmail({
{
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email, to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update', subject: 'Submission Update',
content content,
} idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
]); });
} }
/** /**
@@ -1035,18 +1035,14 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
</div> </div>
`; `;
// Send email to all tutors const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
const emailPromises = tutorsResult.map((tutor) => { if (tutorEmails.length === 0) return;
if (!tutor.email) return Promise.resolve();
return deliverEmail([
{
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: tutor.email,
subject: `[Submitted]: ${exercise.title}`,
content
}
]);
});
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 { listWidgetCourses } from '@cio/db/queries/widget';
import { PLAN } from '@cio/utils/plans'; import { PLAN } from '@cio/utils/plans';
import { import {
ZWidgetPayload, buildWidgetPayload as buildWidgetPayloadShared,
getLayoutCourseLimit,
normalizeWidgetConfig, normalizeWidgetConfig,
resolveWidgetPlanGatedFields, resolveWidgetPlanGatedFields,
type TWidgetLayoutType, type BuildWidgetPayloadCourse,
type TWidgetPayload, type TWidgetPayload
type TWidgetPayloadCategory,
type TWidgetPayloadCourseTag
} from '@cio/utils/validation/widget'; } from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types'; import type { TWidget } from '@db/types';
import * as csstree from 'css-tree'; import * as csstree from 'css-tree';
@@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string {
return `wgt_${encodeBase58(bytes).slice(0, 16)}`; 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) { if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
return ''; return '';
} }
@@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st
return csstree.generate(ast); return csstree.generate(ast);
} }
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string { export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
if (customDomain) { if (customDomain) {
return `https://${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>`; 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( export async function buildWidgetPayload(
widget: TWidget, widget: TWidget,
overrideSelectedCourseIds?: string[] overrideSelectedCourseIds?: string[]
@@ -184,216 +203,47 @@ export async function buildWidgetPayload(
} }
const activePlan = await getActiveOrganizationPlan(widget.organizationId); 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 = const selectedCourseIds =
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId); overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 }); const allCoursesResult = 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 tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
widget.organizationId, widget.organizationId,
resolvedCourses.map((course) => course.id) allCoursesResult.items.map((course) => course.id)
); );
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) { const allCourses = allCoursesResult.items.map((course) =>
resolvedCourses = resolvedCourses.filter((course) => { formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
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)
); );
} 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 planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
const layoutOptions = normalizedConfig.layoutOptions; const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
// 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 orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain); const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
return ZWidgetPayload.parse({ return buildWidgetPayloadShared({
version: 'v1', widget: {
widgetId: widget.id, id: widget.id,
publicKey: widget.publicKey, publicKey: widget.publicKey,
layoutType: widget.layoutType,
selectionMode: widget.selectionMode,
config: normalizedConfig
},
organization: { organization: {
id: organization.id, id: organization.id,
name: organization.name, name: organization.name,
siteName: organization.siteName, siteName: organization.siteName ?? '',
customDomain: organization.customDomain customDomain: organization.customDomain
}, },
layoutType, orgBaseUrl,
selectionMode: widget.selectionMode, allCourses,
design: { selectedCourseIds,
...normalizedConfig,
advanced: {
...normalizedConfig.advanced,
customCss: sanitizedCustomCss
}
},
planGatedFields, planGatedFields,
labels: { planName,
loadMoreLabel: normalizedConfig.content.loadMoreLabel, sanitizedCustomCss
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()
}); });
} }
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) { export async function listWidgetAvailableEditorData(orgId: string) {
const [coursesResult, tagGroups, activePlan] = await Promise.all([ const [coursesResult, tagGroups, activePlan] = await Promise.all([
getOrgCourses({ orgId, limit: 200 }), getOrgCourses({ orgId, limit: 200 }),
@@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) {
); );
return { return {
availableCourses: coursesResult.items.map((course) => ({ availableCourses: coursesResult.items.map((course) =>
id: course.id, formatCourseForWidget(course, tagsByCourseId[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] ?? []
})),
availableTags: tagGroups.flatMap((group) => availableTags: tagGroups.flatMap((group) =>
group.tags.map((tag) => ({ group.tags.map((tag) => ({
id: tag.id, id: tag.id,
@@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) {
groupId: group.id 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, getDefaultWidgetConfig,
normalizeWidgetConfig, normalizeWidgetConfig,
type TCreateWidget, type TCreateWidget,
type TPreviewWidget,
type TRollbackWidget, type TRollbackWidget,
type TUpdateWidget type TUpdateWidget
} from '@cio/utils/validation/widget'; } from '@cio/utils/validation/widget';
import { getOrganizationById } from '@cio/db/queries/organization';
import { import {
buildWidgetPayload, buildWidgetPayload,
generateWidgetPublicKey, generateWidgetPublicKey,
getCourseBaseUrl,
getCourseWidgetScriptUrl, getCourseWidgetScriptUrl,
getWidgetEmbedCode, getWidgetEmbedCode,
listWidgetAvailableEditorData listWidgetAvailableEditorData
@@ -82,26 +83,37 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); 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), listWidgetCourses(widget.id),
listWidgetVersions(orgId, widget.id), listWidgetVersions(orgId, widget.id),
listWidgetAvailableEditorData(orgId), listWidgetAvailableEditorData(orgId)
buildWidgetPayload(widget)
]); ]);
return { return {
widget: { widget: {
...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), selectedCourseIds: selectedCourses.map((course) => course.courseId),
embedCode: getWidgetEmbedCode(widget.publicKey), embedCode: getWidgetEmbedCode(widget.publicKey),
publicScriptUrl: getCourseWidgetScriptUrl() publicScriptUrl: getCourseWidgetScriptUrl()
}, },
organization: {
id: organization.id,
name: organization.name,
siteName: organization.siteName ?? '',
customDomain: organization.customDomain
},
orgBaseUrl: getCourseBaseUrl(organization.siteName ?? '', organization.customDomain),
availableCourses: editorData.availableCourses, availableCourses: editorData.availableCourses,
availableTags: editorData.availableTags, availableTags: editorData.availableTags,
previewPayload,
versions, versions,
planGatedFields: editorData.planGatedFields planGatedFields: editorData.planGatedFields,
planName: editorData.planName
}; };
} catch (error) { } catch (error) {
if (error instanceof AppError) { 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) { export async function getPublishedWidgetPayload(publicKey: string) {
try { try {
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey); const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);
+56 -104
View File
@@ -1,114 +1,66 @@
import type { TCertificateDownload } from '@cio/utils/validation/course'; import {
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare'; DEFAULT_CERTIFICATE_DESIGN,
renderCertificate,
resolveTemplateId,
type CertificateDesign,
type CertificateRenderData
} from '@cio/certificates';
export interface CertificateGenerationResult { import { getCloudflarePdfBuffer, getCloudflarePngBuffer } from '@api/utils/cloudflare';
success: boolean;
buffer?: Buffer; export interface CertificateRenderInput {
error?: string; design: CertificateDesign;
data: CertificateRenderData;
} }
export const generateCertificate = async (data: TCertificateDownload) => { /**
const { html, styles } = generateCertificateHtml(data); * Coerces a stored `course.certificate` JSONB blob (legacy or current) into a
return getCloudflarePdfBuffer(html, styles); * 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 templateId = resolveTemplateId(storedDesign?.templateId ?? legacyTheme);
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
const html = ` const accentColor =
<html> storedDesign?.accentColor && /^#[0-9a-fA-F]{6}$/.test(storedDesign.accentColor)
<head> ? storedDesign.accentColor
<meta charset="utf-8"> : DEFAULT_CERTIFICATE_DESIGN.accentColor;
<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 styles = ` const storedSignatories = Array.isArray(storedDesign?.signatories) ? storedDesign?.signatories : undefined;
body {
font-family: 'Arial', sans-serif; const signatories: CertificateDesign['signatories'] = [
margin: 0; {
padding: 0; name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
background-color: #f5f5f5; 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; return {
margin: 0 auto; templateId,
background-color: white; accentColor,
position: relative; subtitle: storedDesign?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
box-shadow: 0 0 10px rgba(0,0,0,0.1); descriptionOverride: storedDesign?.descriptionOverride,
} signatories,
.header { idFormat: storedDesign?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
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 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'); 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'; } from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people'; import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth'; 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: { export function calcCourseProgressPercent(params: {
lessonsCompleted: number; lessonsCompleted: number;
@@ -69,7 +70,7 @@ export function scheduleCertificationCompletionWork(params: {
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined); const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`; const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', { await enqueueTransactionalEmail('studentCourseCompletion', {
to: studentEmail, to: studentEmail,
fields: { fields: {
orgName: courseRow.orgName, orgName: courseRow.orgName,
@@ -78,7 +79,8 @@ export function scheduleCertificationCompletionWork(params: {
certificateUrl, certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null 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()); await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
@@ -132,3 +132,15 @@ export const agentUploadKeyGenerator = (c: Context): string => {
const baseKey = userKeyGenerator(c); const baseKey = userKeyGenerator(c);
return `${baseKey}:agent:upload`; 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; 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 * Generates presigned URLs for video download
* @param keys Array of video keys * @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/index.ts"],
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"], "@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
"@cio/email/*": ["./node_modules/@cio/email/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/**/*"], "include": ["src/**/*"],
@@ -33,7 +37,9 @@
"references": [ "references": [
{ "path": "../../packages/db" }, { "path": "../../packages/db" },
{ "path": "../../packages/utils" }, { "path": "../../packages/utils" },
{ "path": "../../packages/email" } { "path": "../../packages/email" },
{ "path": "../../packages/jobs" },
{ "path": "../../packages/analytics" }
], ],
"tsc-alias": { "tsc-alias": {
"resolveFullPaths": true, "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": { "dependencies": {
"@ai-sdk/svelte": "^4.0.158", "@ai-sdk/svelte": "^4.0.158",
"@better-auth/sso": "1.4.18", "@better-auth/sso": "1.4.18",
"@cio/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/api": "workspace:*", "@cio/api": "workspace:*",
"@cio/certificates": "workspace:*",
"@cio/db": "workspace:*", "@cio/db": "workspace:*",
"@cio/question-types": "workspace:*", "@cio/question-types": "workspace:*",
"@cio/ui": "workspace:*", "@cio/ui": "workspace:*",
+3 -3
View File
@@ -114,11 +114,11 @@ html .dark {
.prose p, .prose p,
.prose li, .prose li,
.prose blockquote { .prose blockquote {
@apply leading-7; @apply leading-relaxed;
} }
.prose p { .prose p {
@apply my-0; @apply mb-4;
} }
.prose a { .prose a {
@@ -236,7 +236,7 @@ body {
iframe.iframe { iframe.iframe {
width: 100%; width: 100%;
max-width: 800px; max-width: 48rem;
margin: 0 auto; margin: 0 auto;
height: 450px; height: 450px;
border: 1px solid gray; 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 { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes'; 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) => { export const handle: Handle = async (args) => {
const { event } = args; const { event } = args;
const sessionData = await getSessionData(event.cookies); const sessionData = await getSessionData(event.cookies);
@@ -13,6 +28,10 @@ export const handle: Handle = async (args) => {
event.locals = sessionData; event.locals = sessionData;
} }
if (!event.url.pathname.includes('/api')) {
ensureAnalyticsSessionCookie(event.cookies);
}
let response: Response; let response: Response;
if (event.url.pathname.includes('/api')) { 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"> <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 ChatHeader from '$features/ai-assistant/chat-header.svelte';
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte'; import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
import ChatInput from '$features/ai-assistant/chat-input.svelte'; import ChatInput from '$features/ai-assistant/chat-input.svelte';
import { resolve } from '$app/paths'; 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 type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
import { import {
getAgentToolInput,
getAgentToolName, getAgentToolName,
getAgentToolResult, getAgentToolResult,
getAgentToolStatus, getAgentToolStatus,
@@ -14,12 +16,16 @@
type AgentToolPart type AgentToolPart
} from '$features/ai-assistant/utils/tool-parts'; } from '$features/ai-assistant/utils/tool-parts';
import { import {
isAiAssistantOpen, chatDraft,
clearChatDraft,
initialChatModel, initialChatModel,
initialChatPrompt, initialChatPrompt,
initialChatTemplateId,
clearInitialChatModel, clearInitialChatModel,
clearInitialChatPrompt clearInitialChatPrompt,
clearInitialChatTemplateId
} from '$features/ai-assistant/utils/store'; } from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Chat } from '@ai-sdk/svelte'; import { Chat } from '@ai-sdk/svelte';
@@ -28,23 +34,24 @@
import { getMentionableContent } from '$features/course/utils/content'; import { getMentionableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils'; import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api'; import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { openUpgradeModal } from '$lib/utils/functions/org'; import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/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 { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import type { import type {
AiAssistantMessage, AiAssistantMessage,
AiAssistantMessageMetadata, AiAssistantMessageMetadata,
AiAssistantTemplateMetadata,
UploadedDocument UploadedDocument
} from '$features/ai-assistant/utils/types'; } from '$features/ai-assistant/utils/types';
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
import { import {
AI_CHAT_MODEL_STORAGE_KEY, AI_CHAT_MODEL_STORAGE_KEY,
AI_COURSE_CHAT_DEFAULT_WIDTH, AI_ASSISTANT_QUICK_ACTION_ENTRIES,
AI_COURSE_CHAT_MAX_WIDTH, STUDENT_QUICK_ACTION_ENTRIES
AI_COURSE_CHAT_MIN_WIDTH,
AI_COURSE_CHAT_STORAGE_KEY
} from '$features/ai-assistant/utils/constants'; } from '$features/ai-assistant/utils/constants';
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
import { import {
AGENT_MODELS, AGENT_MODELS,
AGENT_MODEL_IDS, AGENT_MODEL_IDS,
@@ -53,14 +60,18 @@
type AgentModelId type AgentModelId
} from '@cio/utils/agent-models'; } 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.'; const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
interface Props { // Read course id from the route. The chat panel is only mounted inside the
/** Course id from route / layout `data` (authoritative for this page). */ // course content layout, so `page.params.id` is always the active course.
courseId: string; const courseId = $derived(page.params?.id as string);
}
let { courseId }: Props = $props();
// Extract current lessonId/exerciseId from route params // Extract current lessonId/exerciseId from route params
const currentLessonId = $derived(page.params?.lessonId as string | undefined); const currentLessonId = $derived(page.params?.lessonId as string | undefined);
@@ -69,15 +80,13 @@
let inputValue = $state(''); let inputValue = $state('');
let uploadedDocument: UploadedDocument | null = $state(null); let uploadedDocument: UploadedDocument | null = $state(null);
let isUploading = $state(false); 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 statusFetchedForCourseId: string | null = $state(null);
let conversationsLoadedForCourseId: string | null = $state(null); let conversationsLoadedForCourseId: string | null = $state(null);
let activeConversationId: 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); let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree); 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) { function getStorageKey(courseId: string) {
return `ai-chat-active-${courseId}`; return `ai-chat-active-${courseId}`;
} }
@@ -197,15 +197,25 @@
} }
} }
// Fetch status and load conversations when the panel opens or route `courseId` changes async function handleRenameConversation(conversationId: string, title: string) {
$effect(() => { const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
if (!$isAiAssistantOpen) {
statusFetchedForCourseId = null;
conversationsLoadedForCourseId = null;
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 (!courseId) return;
if (statusFetchedForCourseId !== courseId) { if (statusFetchedForCourseId !== courseId) {
@@ -216,14 +226,20 @@
if (conversationsLoadedForCourseId !== courseId) { if (conversationsLoadedForCourseId !== courseId) {
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 listForCourseId = courseId;
const savedId = getActiveConversationId(listForCourseId); const savedId = getActiveConversationId(listForCourseId);
aiAssistantApi.listConversations(listForCourseId).then(() => { aiAssistantApi.listConversations(listForCourseId).then(() => {
if (courseId !== listForCourseId) return; 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 (activeConversationId) return;
if (savedId) { if (savedId) {
@@ -323,18 +339,53 @@
} }
}); });
const quickActions = [ function buildTemplateAnswersSummary(
'Upload a document to create a course', templateId: CourseTemplateId,
'Draft a lesson', answers: Record<string, string>,
'Generate questions from this lesson', fields: TemplateFormField[]
'Summarize this lesson', ): string {
'Help me publish this course' 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() { function handleSend() {
if (!inputValue.trim() || chat.status === 'streaming') return; if (!inputValue.trim() || chat.status === 'streaming') return;
const text = inputValue; const text = inputValue;
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
const messageAttachment = uploadedDocument const messageAttachment = uploadedDocument
? { ? {
documentId: uploadedDocument.id, documentId: uploadedDocument.id,
@@ -342,6 +393,18 @@
} }
: undefined; : undefined;
const metadata: AiAssistantMessageMetadata = {};
if (messageAttachment) {
metadata.attachment = messageAttachment;
}
if (templateForFirstMessage) {
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
metadata.template = templateMeta;
pendingInitialTemplateId = null;
}
inputValue = ''; inputValue = '';
// Create conversation eagerly before sending the first message // Create conversation eagerly before sending the first message
@@ -357,7 +420,48 @@
chat.sendMessage({ chat.sendMessage({
text, 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() { function handleAskPlanChanges(message: string) {
inputValue = ''; 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() { function handleResume() {
@@ -441,9 +557,94 @@
goto(resolve(route, {})); 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 isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0); 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( const mentionItems = $derived(
getMentionableContent(courseApi.course).map((item) => ({ getMentionableContent(courseApi.course).map((item) => ({
id: item.id, id: item.id,
@@ -455,9 +656,31 @@
// Show an activity card whenever the agent calls any tool. // Show an activity card whenever the agent calls any tool.
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way. // Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
const planExecutionState = $derived.by(() => { 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 continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
const reachedStepLimit = continuation?.reason === 'step_limit'; const reachedStepLimit = continuation?.reason === 'step_limit';
@@ -466,9 +689,29 @@
) as AgentToolPart[]; ) as AgentToolPart[];
// generate_course_plan renders its own PlanView — exclude it from the card // 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 steps: ProgressStep[] = toolParts.flatMap((part) => {
const toolName = getAgentToolName(part); const toolName = getAgentToolName(part);
@@ -479,9 +722,12 @@
const result = getAgentToolResult(part) as Record<string, unknown> | undefined; const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
const status = getAgentToolStatus(part); 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'); const allDone = steps.every((s) => s.status === 'completed');
@@ -494,55 +740,60 @@
const toolName = getAgentToolName(part); const toolName = getAgentToolName(part);
return toolName ? MUTATION_TOOLS.includes(toolName) : false; return toolName ? MUTATION_TOOLS.includes(toolName) : false;
}); });
const title = hasMutations ? 'Applying changes' : 'Working'; const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
const currentAction = steps.find((s) => s.status === 'in_progress')?.label; 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( const conversationTitle = $derived(
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null 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(() => { onMount(() => {
try {
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
if (Number.isFinite(storedWidth)) {
chatWidth = clampChatWidth(storedWidth);
}
} catch {
// localStorage unavailable
}
try { try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY); const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
@@ -552,28 +803,22 @@
} catch { } catch {
// localStorage unavailable // localStorage unavailable
} }
hasLoadedChatWidth = true;
return () => {
clearChatResizeListeners();
};
});
onDestroy(() => {
clearChatResizeListeners();
}); });
$effect(() => { $effect(() => {
const prompt = $initialChatPrompt; const prompt = $initialChatPrompt;
if (!prompt || !$isAiAssistantOpen || !courseId) return; if (!prompt || !courseId) return;
const model = $initialChatModel; const model = $initialChatModel;
const templateFromHome = $initialChatTemplateId;
clearInitialChatPrompt(); clearInitialChatPrompt();
clearInitialChatModel(); clearInitialChatModel();
clearInitialChatTemplateId();
tick().then(() => { tick().then(() => {
pendingInitialTemplateId = templateFromHome ?? null;
if (model && isAgentModelId(model)) { if (model && isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) { if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal(); openUpgradeModal();
@@ -589,21 +834,37 @@
}); });
$effect(() => { $effect(() => {
if (!hasLoadedChatWidth) { const draft = $chatDraft;
if (!draft || !draft.text) {
return; return;
} }
try { clearChatDraft();
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
} catch { if (draft.mode === 'new') {
// localStorage unavailable 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> </script>
{#snippet chatPanelContent()} <div class="flex min-h-0 flex-1 flex-col">
<ChatHeader <ChatHeader
{tokenUsage} {tokenUsage}
{isStudent}
{studentMessageUsage}
{conversationTitle} {conversationTitle}
conversations={aiAssistantApi.conversations} conversations={aiAssistantApi.conversations}
{activeConversationId} {activeConversationId}
@@ -611,18 +872,21 @@
onNewChat={startNewChat} onNewChat={startNewChat}
onLoadConversation={loadConversation} onLoadConversation={loadConversation}
onDeleteConversation={handleDeleteConversation} onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
/> />
<ChatMessageList <ChatMessageList
messages={chat.messages} messages={chat.messages}
status={chat.status}
{isStreaming} {isStreaming}
{isStudent}
{courseId} {courseId}
{planExecutionState} {planExecutionState}
{quickActions} {quickActions}
onQuickAction={handleQuickAction} onQuickAction={handleQuickAction}
onImplementPlan={handleImplementPlan} onImplementPlan={handleImplementPlan}
onAskPlanChanges={handleAskPlanChanges} onAskPlanChanges={handleAskPlanChanges}
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
onSkipTemplateForm={handleSkipTemplateForm}
onStop={handleStop} onStop={handleStop}
onResume={handleResume} onResume={handleResume}
onMentionClick={handleMentionClick} onMentionClick={handleMentionClick}
@@ -633,9 +897,15 @@
{isStreaming} {isStreaming}
{isExhausted} {isExhausted}
{isUploading} {isUploading}
{contextFullBusy}
compactConversationDisabled={!activeConversationId}
onCompactConversation={handleCompactConversation}
{uploadedDocument} {uploadedDocument}
{mentionItems} {mentionItems}
{selectedModel} {selectedModel}
{contextUsage}
{isStudent}
{tutorBlocked}
lockedModelIds={$isFreePlan ? paidModelIds : []} lockedModelIds={$isFreePlan ? paidModelIds : []}
error={chat.error} error={chat.error}
onSend={handleSend} onSend={handleSend}
@@ -644,37 +914,6 @@
onRemoveDocument={handleRemoveDocument} onRemoveDocument={handleRemoveDocument}
onSelectModel={handleSelectModel} onSelectModel={handleSelectModel}
onLockedModelSelect={handleLockedModelSelect} onLockedModelSelect={handleLockedModelSelect}
onStartNewChatWithSummary={handleStartNewChatWithSummary}
/> />
{/snippet} </div>
{#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>
@@ -4,7 +4,9 @@ import type {
AgentConversationCreateData, AgentConversationCreateData,
AgentConversationSummary, AgentConversationSummary,
AgentStatusData, AgentStatusData,
AiAssistantMessage AiAssistantMessage,
CompactConversationRequest,
CompactConversationSuccess
} from '../utils/types'; } from '../utils/types';
class AiAssistantApi extends BaseApiWithErrors { class AiAssistantApi extends BaseApiWithErrors {
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
return generatedTitle; 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(); export const aiAssistantApi = new AiAssistantApi();
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Popover from '@cio/ui/base/popover'; 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 SparklesIcon from '@lucide/svelte/icons/sparkles';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
@@ -22,8 +24,15 @@
remaining: number; remaining: number;
} }
interface StudentMessageUsage {
used: number;
cap: number;
}
interface Props { interface Props {
tokenUsage: TokenUsage | null; tokenUsage: TokenUsage | null;
isStudent: boolean;
studentMessageUsage: StudentMessageUsage | null;
conversations: Conversation[]; conversations: Conversation[];
activeConversationId: string | null; activeConversationId: string | null;
conversationTitle: string | null; conversationTitle: string | null;
@@ -31,25 +40,132 @@
onNewChat: () => void; onNewChat: () => void;
onLoadConversation: (id: string) => void; onLoadConversation: (id: string) => void;
onDeleteConversation: (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 { let {
tokenUsage, tokenUsage,
isStudent,
studentMessageUsage,
conversations, conversations,
activeConversationId, activeConversationId,
conversationTitle, conversationTitle,
isNewChatDisabled, isNewChatDisabled,
onNewChat, onNewChat,
onLoadConversation, onLoadConversation,
onDeleteConversation onDeleteConversation,
onRenameConversation
}: Props = $props(); }: Props = $props();
let historyPopoverOpen = $state(false); 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) { function handleLoadConversation(id: string) {
onLoadConversation(id); onLoadConversation(id);
historyPopoverOpen = false; 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> </script>
<div class="border-b px-4 py-3"> <div class="border-b px-4 py-3">
@@ -59,7 +175,32 @@
<div class="min-w-0"> <div class="min-w-0">
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3> <h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
{#if conversationTitle && conversationTitle !== 'New conversation'} {#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} {/if}
</div> </div>
</div> </div>
@@ -103,7 +244,28 @@
</IconButton> </IconButton>
</div> </div>
</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 totalBudget = tokenUsage.used + tokenUsage.remaining}
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))} {@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
<div class="mt-2"> <div class="mt-2">
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Button } from '@cio/ui/base/button'; 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 { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons'; import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import PaperclipIcon from '@lucide/svelte/icons/paperclip'; import PaperclipIcon from '@lucide/svelte/icons/paperclip';
@@ -14,6 +17,10 @@
import { openUpgradeModal } from '$lib/utils/functions/org'; import { openUpgradeModal } from '$lib/utils/functions/org';
import { ModelPicker } from '@cio/ui/custom/model-picker'; import { ModelPicker } from '@cio/ui/custom/model-picker';
import type { AgentModelId } from '@cio/utils/agent-models'; 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 { interface UploadedDocument {
id: string; id: string;
@@ -25,17 +32,25 @@
isStreaming: boolean; isStreaming: boolean;
isExhausted: boolean; isExhausted: boolean;
isUploading: boolean; isUploading: boolean;
contextFullBusy: null | 'compact' | 'new_chat';
compactConversationDisabled?: boolean;
error: Error | null | undefined; error: Error | null | undefined;
mentionItems: MentionItem[]; mentionItems: MentionItem[];
uploadedDocument: UploadedDocument | null; uploadedDocument: UploadedDocument | null;
selectedModel: AgentModelId; selectedModel: AgentModelId;
lockedModelIds?: readonly 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; onSend: () => void;
onStop: () => void; onStop: () => void;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
onRemoveDocument: () => void; onRemoveDocument: () => void;
onSelectModel: (id: AgentModelId) => void; onSelectModel: (id: AgentModelId) => void;
onLockedModelSelect?: (id: AgentModelId) => void; onLockedModelSelect?: (id: AgentModelId) => void;
onStartNewChatWithSummary: () => void;
onCompactConversation: () => void;
} }
let { let {
@@ -43,23 +58,64 @@
isStreaming, isStreaming,
isExhausted, isExhausted,
isUploading, isUploading,
contextFullBusy,
compactConversationDisabled = false,
error, error,
mentionItems, mentionItems,
uploadedDocument, uploadedDocument,
selectedModel, selectedModel,
lockedModelIds = [], lockedModelIds = [],
contextUsage,
isStudent = false,
tutorBlocked = null,
onSend, onSend,
onStop, onStop,
onFileSelect, onFileSelect,
onRemoveDocument, onRemoveDocument,
onSelectModel, onSelectModel,
onLockedModelSelect onLockedModelSelect,
onStartNewChatWithSummary,
onCompactConversation
}: Props = $props(); }: 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 fileInputEl: HTMLInputElement | undefined = $state();
let chatTextareaRef: HTMLTextAreaElement | null = $state(null); let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
let wasBusy = $state(false); 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(() => { $effect(() => {
const isBusy = isStreaming || isUploading; const isBusy = isStreaming || isUploading;
@@ -102,6 +158,31 @@
return t.get('ai_assistant.mention_lesson'); 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> </script>
<input <input
@@ -112,7 +193,22 @@
onchange={handleFileChange} 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} {#if isExhausted}
<div <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" 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} {/if}
</div> </div>
{:else} {:else}
{#if isStreaming} {#if isStreaming && !agentRunningWarningDismissed && !isStudent}
<div <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> </div>
{/if} {/if}
{#if error} {#if displayErrorMessage}
<div <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" 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> </div>
{/if} {/if}
@@ -188,6 +294,7 @@
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
{#if !isStudent}
<button <button
onclick={handlePaperclipClick} onclick={handlePaperclipClick}
disabled={isUploading} disabled={isUploading}
@@ -196,14 +303,19 @@
> >
<PaperclipIcon size={16} /> <PaperclipIcon size={16} />
</button> </button>
{/if}
<div class="flex-1"></div> <div class="flex-1"></div>
{#if contextUsage && contextUsage.usedTokens > 0}
<ContextIndicator {contextUsage} />
{/if}
{#if isStreaming} {#if isStreaming}
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0"> <Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
<SquareIcon size={12} /> <SquareIcon size={12} />
</Button> </Button>
{:else} {:else if !isStudent}
<ModelPicker <ModelPicker
value={selectedModel} value={selectedModel}
disabled={isStreaming} disabled={isStreaming}
@@ -211,9 +323,14 @@
onChange={onSelectModel} onChange={onSelectModel}
onLockedSelect={onLockedModelSelect} onLockedSelect={onLockedModelSelect}
/> />
{:else}
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
{$t('ai_assistant.send')}
</Button>
{/if} {/if}
{/snippet} {/snippet}
</ChatTextarea> </ChatTextarea>
</div> </div>
{/if} {/if}
</div> </div>
{/if}
@@ -5,26 +5,40 @@
import MessageBubble from '$features/ai-assistant/message-bubble.svelte'; import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
import ProgressCard from '$features/ai-assistant/progress-card.svelte'; import ProgressCard from '$features/ai-assistant/progress-card.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels'; import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types'; 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 { interface PlanExecutionState {
title: string; titleKey: string;
steps: ProgressStep[]; steps: ProgressStep[];
currentAction?: string; currentActionLine?: ToolLineUi;
isStopped: boolean; isStopped: boolean;
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
hasMutations?: boolean;
} }
interface Props { interface Props {
messages: AiAssistantMessage[]; messages: AiAssistantMessage[];
status: string;
isStreaming: boolean; isStreaming: boolean;
isStudent: boolean;
courseId: string; courseId: string;
planExecutionState: PlanExecutionState | null; planExecutionState: PlanExecutionState | null;
quickActions: string[]; quickActions: QuickActionOption[];
onQuickAction: (action: string) => void; onQuickAction: (action: string) => void;
onImplementPlan: (editedPlan: unknown) => 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; onStop: () => void;
onResume: () => void; onResume: () => void;
onMentionClick: (route: string) => void; onMentionClick: (route: string) => void;
@@ -32,14 +46,16 @@
let { let {
messages, messages,
status,
isStreaming, isStreaming,
isStudent,
courseId, courseId,
planExecutionState, planExecutionState,
quickActions, quickActions,
onQuickAction, onQuickAction,
onImplementPlan, onImplementPlan,
onAskPlanChanges, onAskPlanChanges,
onSubmitTemplateAnswers,
onSkipTemplateForm,
onStop, onStop,
onResume, onResume,
onMentionClick onMentionClick
@@ -48,13 +64,34 @@
let messagesContainer: HTMLDivElement | undefined = $state(); let messagesContainer: HTMLDivElement | undefined = $state();
let lastMessageCount = $state(0); let lastMessageCount = $state(0);
let lastStepsCount = $state(0); let lastStepsCount = $state(0);
let lastCurrentAction = $state(''); let lastCurrentActionSig = $state('');
let lastStreamingSig = $state(0);
const isEmpty = $derived(messages.length === 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(() => { requestAnimationFrame(() => {
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
}); });
} }
@@ -67,6 +104,16 @@
scrollToBottom(); 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(() => { $effect(() => {
if (!messagesContainer || !planExecutionState) return; if (!messagesContainer || !planExecutionState) return;
@@ -77,9 +124,10 @@
return; return;
} }
const action = planExecutionState.currentAction ?? ''; const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
if (action !== lastCurrentAction) {
lastCurrentAction = action; if (actionSig !== lastCurrentActionSig) {
lastCurrentActionSig = actionSig;
scrollToBottom(); scrollToBottom();
} }
}); });
@@ -92,17 +140,17 @@
<div class="flex flex-col items-center gap-2 text-center"> <div class="flex flex-col items-center gap-2 text-center">
<SparklesIcon size={32} class="ui:text-muted-foreground" /> <SparklesIcon size={32} class="ui:text-muted-foreground" />
<p class="ui:text-muted-foreground text-sm"> <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> </p>
</div> </div>
<div class="flex flex-wrap justify-center gap-2"> <div class="flex flex-wrap justify-center gap-2">
{#each quickActions as action (action)} {#each quickActions as option (option.key)}
<button <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" 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> </button>
{/each} {/each}
</div> </div>
@@ -110,11 +158,31 @@
{:else} {:else}
<!-- Message list --> <!-- Message list -->
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{#each messages as message (message.id)} {#each messages as message, messageIndex (message.id)}
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} /> {@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} {/each}
{#if status === 'submitted' && isStreaming} {#if isStreaming && (isStudent || !planExecutionState)}
<div class="flex items-start"> <div class="flex items-start">
<div class="ui:bg-muted rounded-lg px-3 py-2"> <div class="ui:bg-muted rounded-lg px-3 py-2">
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" /> <LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
@@ -122,12 +190,14 @@
</div> </div>
{/if} {/if}
{#if planExecutionState} {#if planExecutionState && !isStudent}
<div class="mt-2"> <div class="mt-2">
<ProgressCard <ProgressCard
title={planExecutionState.title} titleKey={planExecutionState.titleKey}
steps={planExecutionState.steps} steps={planExecutionState.steps}
currentAction={planExecutionState.currentAction} currentActionLine={planExecutionState.currentActionLine}
{courseId}
onNavigate={onMentionClick}
onStop={isStreaming ? onStop : undefined} onStop={isStreaming ? onStop : undefined}
isStopped={planExecutionState.isStopped} 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