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