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*
|
tsconfig.tsbuildinfo*
|
||||||
|
|
||||||
|
# Stray TS build artifacts emitted next to sources (e.g. from `tsc <file>` without project)
|
||||||
|
apps/api/src/**/*.js
|
||||||
|
apps/api/src/**/*.js.map
|
||||||
|
apps/api/src/**/*.d.ts
|
||||||
|
!apps/api/src/types/css-tree.d.ts
|
||||||
|
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
|
|
||||||
|
company/
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This document collects implementation rules and workflow conventions for code changes.
|
This document collects implementation rules and workflow conventions for code changes.
|
||||||
|
|
||||||
|
## Research Requirements
|
||||||
|
|
||||||
|
When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway.
|
||||||
|
|
||||||
## Naming Convention
|
## Naming Convention
|
||||||
|
|
||||||
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
||||||
@@ -77,6 +81,40 @@ import { accountRouter } from '@api/routes/account';
|
|||||||
export const app = new Hono().route('/account', accountRouter);
|
export const app = new Hono().route('/account', accountRouter);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/routes/internal/index.ts
|
||||||
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { internalAnalyticsRouter } from './analytics';
|
||||||
|
import { internalComplianceRouter } from './compliance';
|
||||||
|
|
||||||
|
export const internalRouter = new Hono()
|
||||||
|
.route('/compliance', internalComplianceRouter)
|
||||||
|
.route('/analytics', internalAnalyticsRouter);
|
||||||
|
|
||||||
|
// apps/api/src/app.ts
|
||||||
|
import { internalRouter } from '@api/routes/internal';
|
||||||
|
export const app = new Hono().route('/internal', internalRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/routes/internal/index.ts
|
||||||
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { internalAnalyticsRouter } from './analytics';
|
||||||
|
import { internalComplianceRouter } from './compliance';
|
||||||
|
|
||||||
|
export const internalRouter = new Hono()
|
||||||
|
.route('/compliance', internalComplianceRouter)
|
||||||
|
.route('/analytics', internalAnalyticsRouter);
|
||||||
|
|
||||||
|
// apps/api/src/app.ts
|
||||||
|
import { internalRouter } from '@api/routes/internal';
|
||||||
|
export const app = new Hono().route('/internal', internalRouter);
|
||||||
|
```
|
||||||
|
|
||||||
### Step 6: Build
|
### Step 6: Build
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @cio/api build
|
pnpm --filter @cio/api build
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ BETTER_AUTH_SECRET=""
|
|||||||
AUTH_BEARER_TOKEN=""
|
AUTH_BEARER_TOKEN=""
|
||||||
AUTH_COOKIE_DOMAIN=""
|
AUTH_COOKIE_DOMAIN=""
|
||||||
|
|
||||||
|
# Email provider — used by the API only for auth-flow emails (forgot
|
||||||
|
# password, password-reset confirmation, email verification, email-change
|
||||||
|
# verification). Every other email is sent by @cio/jobs-worker; see
|
||||||
|
# apps/jobs/.env.example. Both processes can point at the same SMTP server.
|
||||||
SMTP_HOST=""
|
SMTP_HOST=""
|
||||||
SMTP_PORT=""
|
SMTP_PORT=""
|
||||||
SMTP_USER=""
|
SMTP_USER=""
|
||||||
SMTP_SENDER=""
|
SMTP_SENDER=""
|
||||||
SMTP_PASSWORD=""
|
SMTP_PASSWORD=""
|
||||||
|
|
||||||
|
# Optional Zoho ZeptoMail token. When set, @cio/email uses Zoho instead of
|
||||||
|
# nodemailer/SMTP. Same dual-process note as SMTP above.
|
||||||
|
# ZOHO_TOKEN=""
|
||||||
|
|
||||||
# Optional - MinIO (S3-compatible, for Docker / on-premise self-hosting)
|
# Optional - MinIO (S3-compatible, for Docker / on-premise self-hosting)
|
||||||
# When API runs in Docker: use http://minio:9000. For local API: use http://localhost:9000
|
# When API runs in Docker: use http://minio:9000. For local API: use http://localhost:9000
|
||||||
MINIO_ROOT_USER=""
|
MINIO_ROOT_USER=""
|
||||||
@@ -34,6 +42,19 @@ CLOUDFLARE_BUCKET_DOMAIN=""
|
|||||||
CLOUDFLARE_SECRET_ACCESS_KEY=""
|
CLOUDFLARE_SECRET_ACCESS_KEY=""
|
||||||
SENTRY_DNS=""
|
SENTRY_DNS=""
|
||||||
|
|
||||||
|
# BullMQ dashboard (/admin/queues) ───────────────────────────────────────────
|
||||||
|
# In dev the dashboard is open. In production it's hidden unless ONE of the
|
||||||
|
# following is set; failing requests get 404 (not 401) so the route stays
|
||||||
|
# invisible to scanners.
|
||||||
|
#
|
||||||
|
# Comma-separated allowlist of Better Auth user emails — log in normally,
|
||||||
|
# visit /admin/queues, the middleware checks your session.
|
||||||
|
# QUEUE_DASHBOARD_ADMIN_EMAILS="you@yourdomain.com,partner@yourdomain.com"
|
||||||
|
#
|
||||||
|
# Bearer-token escape hatch for curl / scripts. Pass via
|
||||||
|
# `Authorization: Bearer <token>` or `?token=<token>`.
|
||||||
|
# QUEUE_DASHBOARD_TOKEN=""
|
||||||
|
|
||||||
# Enterprise (Optional)
|
# Enterprise (Optional)
|
||||||
# Required only when using Enterprise-only features (SSO, token-auth, no-tracking)
|
# Required only when using Enterprise-only features (SSO, token-auth, no-tracking)
|
||||||
LICENSE_KEY=""
|
LICENSE_KEY=""
|
||||||
@@ -51,6 +72,11 @@ GOOGLE_API_KEY=""
|
|||||||
ANTHROPIC_API_KEY=""
|
ANTHROPIC_API_KEY=""
|
||||||
MOONSHOT_API_KEY=""
|
MOONSHOT_API_KEY=""
|
||||||
|
|
||||||
|
# Jina Reader — optional Bearer for higher rate limits when using fetch_documentation_url
|
||||||
|
# JINA_API_KEY=""
|
||||||
|
# AGENT_MAX_FETCHES_PER_CONVERSATION=15
|
||||||
|
# AGENT_MAX_FETCHES_PER_ORG_PER_DAY=500
|
||||||
|
|
||||||
# Tinybird event tracking for AI observability (optional — silently skipped when not set)
|
# Tinybird event tracking for AI observability (optional — silently skipped when not set)
|
||||||
# TINYBIRD_TOKEN=""
|
# TINYBIRD_TOKEN=""
|
||||||
# TINYBIRD_BASE_URL="https://api.tinybird.co"
|
# TINYBIRD_BASE_URL="https://api.tinybird.co"
|
||||||
@@ -1 +1,2 @@
|
|||||||
static
|
static
|
||||||
|
dist
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build",
|
"build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build",
|
||||||
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
|
"dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"",
|
||||||
"dev:server": "tsx watch src/index.ts",
|
"dev:server": "tsx watch src/index.ts",
|
||||||
"dev:tsc": "tsc -p tsconfig.json --watch",
|
"dev:tsc": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest NODE_ENV=test",
|
"test": "vitest NODE_ENV=test",
|
||||||
@@ -40,9 +40,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.388.0",
|
"@aws-sdk/client-s3": "^3.388.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
"@aws-sdk/s3-request-presigner": "^3.675.0",
|
||||||
|
"@bull-board/api": "^7.0.0",
|
||||||
|
"@bull-board/hono": "^7.0.0",
|
||||||
"@cio/ai-assistant": "workspace:*",
|
"@cio/ai-assistant": "workspace:*",
|
||||||
|
"@cio/analytics": "workspace:*",
|
||||||
|
"@cio/certificates": "workspace:*",
|
||||||
"@cio/db": "workspace:*",
|
"@cio/db": "workspace:*",
|
||||||
"@cio/email": "workspace:*",
|
"@cio/email": "workspace:*",
|
||||||
|
"@cio/jobs": "workspace:*",
|
||||||
"@cio/question-types": "workspace:*",
|
"@cio/question-types": "workspace:*",
|
||||||
"@cio/utils": "workspace:*",
|
"@cio/utils": "workspace:*",
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
@@ -76,7 +81,7 @@
|
|||||||
"@typescript-eslint/parser": "7.0.1",
|
"@typescript-eslint/parser": "7.0.1",
|
||||||
"@vitest/coverage-v8": "^1.2.2",
|
"@vitest/coverage-v8": "^1.2.2",
|
||||||
"eslint": "^9.31.0",
|
"eslint": "^9.31.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "3.6.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
|
|||||||
@@ -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';
|
import { logger } from 'hono/logger';
|
||||||
// ROUTES
|
// ROUTES
|
||||||
import { mailRouter } from '@api/routes/mail';
|
import { mailRouter } from '@api/routes/mail';
|
||||||
|
import { jobsRouter } from '@api/routes/jobs';
|
||||||
import { mediaRouter } from '@api/routes/media';
|
import { mediaRouter } from '@api/routes/media';
|
||||||
import { onboardingRouter } from '@api/routes/onboarding';
|
import { onboardingRouter } from '@api/routes/onboarding';
|
||||||
import { organizationRouter } from '@api/routes/organization';
|
import { organizationRouter } from '@api/routes/organization';
|
||||||
@@ -31,7 +32,8 @@ import { signupGuard } from '@api/middlewares/signup-guard';
|
|||||||
import { programRouter } from '@api/routes/program';
|
import { programRouter } from '@api/routes/program';
|
||||||
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
|
import { unsplashRouter } from '@api/routes/unsplash/unsplash';
|
||||||
import { agentRouter } from '@api/routes/agent';
|
import { agentRouter } from '@api/routes/agent';
|
||||||
import { internalComplianceRouter } from '@api/routes/internal/compliance';
|
import { internalRouter } from '@api/routes/internal';
|
||||||
|
import { mountQueueDashboard } from '@api/routes/admin/queues';
|
||||||
|
|
||||||
// Create Hono app with chaining for RPC support
|
// Create Hono app with chaining for RPC support
|
||||||
export const app = new Hono()
|
export const app = new Hono()
|
||||||
@@ -43,7 +45,7 @@ export const app = new Hono()
|
|||||||
'*',
|
'*',
|
||||||
cors({
|
cors({
|
||||||
origin: TRUSTED_ORIGINS,
|
origin: TRUSTED_ORIGINS,
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
|
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
|
||||||
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
||||||
maxAge: 600,
|
maxAge: 600,
|
||||||
@@ -94,6 +96,7 @@ export const app = new Hono()
|
|||||||
.route('/domain', domainRouter)
|
.route('/domain', domainRouter)
|
||||||
.route('/mail', mailRouter)
|
.route('/mail', mailRouter)
|
||||||
.route('/media', mediaRouter)
|
.route('/media', mediaRouter)
|
||||||
|
.route('/jobs', jobsRouter)
|
||||||
.route('/license', licenseRouter)
|
.route('/license', licenseRouter)
|
||||||
.route('/organization', organizationRouter)
|
.route('/organization', organizationRouter)
|
||||||
.route('/organization/sso', organizationSsoRouter)
|
.route('/organization/sso', organizationSsoRouter)
|
||||||
@@ -107,7 +110,7 @@ export const app = new Hono()
|
|||||||
.route('/program', programRouter)
|
.route('/program', programRouter)
|
||||||
.route('/unsplash', unsplashRouter)
|
.route('/unsplash', unsplashRouter)
|
||||||
.route('/widgets', publicWidgetsRouter)
|
.route('/widgets', publicWidgetsRouter)
|
||||||
.route('/internal/compliance', internalComplianceRouter)
|
.route('/internal', internalRouter)
|
||||||
.route('/agent', agentRouter)
|
.route('/agent', agentRouter)
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
@@ -115,3 +118,7 @@ export const app = new Hono()
|
|||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
return c.json({ error: 'Internal Server Error' }, 500);
|
return c.json({ error: 'Internal Server Error' }, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dev-only BullMQ dashboard at /admin/queues. Mounted after the typed RPC
|
||||||
|
// chain so it doesn't pollute the client type.
|
||||||
|
mountQueueDashboard(app);
|
||||||
|
|||||||
@@ -48,7 +48,47 @@ const envSchema = z.object({
|
|||||||
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
|
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
|
||||||
LICENSE_KEY: z.string().optional(),
|
LICENSE_KEY: z.string().optional(),
|
||||||
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
|
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
|
||||||
DASHBOARD_ORIGIN: z.string().optional()
|
DASHBOARD_ORIGIN: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* Comma-separated list of Better Auth user emails allowed to access the
|
||||||
|
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the
|
||||||
|
* dashboard hidden everywhere except dev.
|
||||||
|
*/
|
||||||
|
QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(),
|
||||||
|
/**
|
||||||
|
* Bearer-token escape hatch for /admin/queues — useful for curl scripts or
|
||||||
|
* when SSO is unavailable. When set, requests with `Authorization: Bearer
|
||||||
|
* <token>` (or `?token=<token>`) bypass the email allowlist.
|
||||||
|
*/
|
||||||
|
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
|
||||||
|
/** Optional Bearer token for Jina Reader (higher rate limits). */
|
||||||
|
JINA_API_KEY: z.string().optional(),
|
||||||
|
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */
|
||||||
|
AGENT_MAX_FETCHES_PER_CONVERSATION: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === '') {
|
||||||
|
return 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(v);
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 15;
|
||||||
|
}),
|
||||||
|
/** Max fetch_documentation_url calls per org per UTC day (default 500). */
|
||||||
|
AGENT_MAX_FETCHES_PER_ORG_PER_DAY: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === '') {
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(v);
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500;
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// API-specific constants (file size limits)
|
// API-specific constants (file size limits)
|
||||||
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
export const MAX_FILE_SIZE = 800 * 1024 * 1024; // 800MB
|
||||||
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Context, Next } from 'hono';
|
||||||
|
|
||||||
|
import * as schema from '@db/schema';
|
||||||
|
|
||||||
|
import { ErrorCodes } from '@api/utils/errors';
|
||||||
|
import { db } from '@cio/db/drizzle';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks mutating requests on workspaces flagged read-only by the
|
||||||
|
* multi-workspace downgrade grace job. Read methods always pass through.
|
||||||
|
*/
|
||||||
|
export const workspaceNotReadOnlyMiddleware = async (c: Context, next: Next) => {
|
||||||
|
const method = c.req.method.toUpperCase();
|
||||||
|
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = c.req.header('cio-org-id');
|
||||||
|
if (!orgId) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ readOnlyUntil: schema.organization.readOnlyUntil })
|
||||||
|
.from(schema.organization)
|
||||||
|
.where(eq(schema.organization.id, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (row?.readOnlyUntil && new Date(row.readOnlyUntil) > new Date()) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Workspace is read-only while account is over plan limits',
|
||||||
|
code: ErrorCodes.WORKSPACE_READ_ONLY
|
||||||
|
},
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('workspaceNotReadOnlyMiddleware error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
@@ -2,12 +2,14 @@ import { getAccountData, updateUser } from '@api/services/account';
|
|||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { ZUpdateProfile } from '@cio/utils/validation/account';
|
import { ZUpdateProfile } from '@cio/utils/validation/account';
|
||||||
|
import { accountWorkspacesRouter } from '@api/routes/account/workspaces';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const accountRouter = new Hono()
|
export const accountRouter = new Hono()
|
||||||
|
.route('/', accountWorkspacesRouter)
|
||||||
.get('/', authMiddleware, async (c) => {
|
.get('/', authMiddleware, async (c) => {
|
||||||
const user = c.get('user')!;
|
const user = c.get('user')!;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
ZAgentCreditPurchase,
|
||||||
ZAgentCreditsBody,
|
ZAgentCreditsBody,
|
||||||
ZAgentGenerateCourseTitleBody,
|
ZAgentGenerateCourseTitleBody,
|
||||||
ZAgentStatusQuery
|
ZAgentGenerateTextBody,
|
||||||
|
ZAgentStatusQuery,
|
||||||
|
ZAgentSummarizeBody,
|
||||||
|
ZTutorUsageQuery,
|
||||||
|
ZTutorUsagePeriod,
|
||||||
|
ZTutorUsageUserParam
|
||||||
} from '@cio/utils/validation/agent';
|
} from '@cio/utils/validation/agent';
|
||||||
import {
|
import {
|
||||||
addCredits,
|
addCredits,
|
||||||
@@ -23,15 +28,32 @@ import {
|
|||||||
isOrgOnPaidPlan,
|
isOrgOnPaidPlan,
|
||||||
recordTokenUsage
|
recordTokenUsage
|
||||||
} from '@api/services/agent/usage';
|
} from '@api/services/agent/usage';
|
||||||
|
import {
|
||||||
|
enforceStudentTutorPolicy,
|
||||||
|
getStudentTutorStatus,
|
||||||
|
getTutorCapStatusSummary,
|
||||||
|
getTutorLearnerDetail,
|
||||||
|
getTutorLearnerLeaderboard,
|
||||||
|
incrementStudentTutorCount
|
||||||
|
} from '@api/services/agent/tutor-usage';
|
||||||
|
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
|
||||||
import { parseAndStoreDocument } from '@api/services/agent/document';
|
import { parseAndStoreDocument } from '@api/services/agent/document';
|
||||||
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
|
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
|
||||||
import { generateCourseMeta } from '@api/services/agent/title-generation';
|
import { generateCourseMeta } from '@api/services/agent/title-generation';
|
||||||
|
import { generateFieldText } from '@api/services/agent/text-generation';
|
||||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||||
import { getChatConversation } from '@cio/db/queries/agent';
|
import { getChatConversation } from '@cio/db/queries/agent';
|
||||||
import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant';
|
import {
|
||||||
|
AgentRole,
|
||||||
|
AIProvider,
|
||||||
|
MAX_STEPS_PER_ROUND,
|
||||||
|
getCourseTemplate,
|
||||||
|
type AgentContext,
|
||||||
|
type AgentStatus
|
||||||
|
} from '@cio/ai-assistant';
|
||||||
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||||
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
|
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
|
||||||
import { buildSystemPrompt } from '@cio/ai-assistant/prompt';
|
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
|
||||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||||
import { redis } from '@api/utils/redis/redis';
|
import { redis } from '@api/utils/redis/redis';
|
||||||
import { db } from '@cio/db';
|
import { db } from '@cio/db';
|
||||||
@@ -41,14 +63,17 @@ import { listCourseSections } from '@api/services/course/section';
|
|||||||
import { getLesson } from '@api/services/lesson/lesson';
|
import { getLesson } from '@api/services/lesson/lesson';
|
||||||
import { getExercise } from '@api/services/exercise/exercise';
|
import { getExercise } from '@api/services/exercise/exercise';
|
||||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||||
|
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
|
||||||
import {
|
import {
|
||||||
collectDocumentIds,
|
collectDocumentIds,
|
||||||
|
getActiveCourseTemplateId,
|
||||||
getLatestImplementationPlan,
|
getLatestImplementationPlan,
|
||||||
loadDocumentsText,
|
loadDocumentsText,
|
||||||
verifyExerciseBelongsToCourse,
|
verifyExerciseBelongsToCourse,
|
||||||
verifyLessonBelongsToCourse
|
verifyLessonBelongsToCourse
|
||||||
} from '@api/services/agent/chat-context';
|
} from '@api/services/agent/chat-context';
|
||||||
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
||||||
|
import { summarizeConversation } from '@api/services/agent/summarize';
|
||||||
import { agentHistoryRouter } from './history';
|
import { agentHistoryRouter } from './history';
|
||||||
|
|
||||||
const agentCoreRouter = new Hono()
|
const agentCoreRouter = new Hono()
|
||||||
@@ -63,7 +88,8 @@ const agentCoreRouter = new Hono()
|
|||||||
const status: AgentStatus = {
|
const status: AgentStatus = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
role: AgentRole.STUDENT,
|
role: AgentRole.STUDENT,
|
||||||
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }
|
usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 },
|
||||||
|
tutor: { enabled: false, capRemaining: null, cap: null, enforced: false }
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json({ success: true, data: status });
|
return c.json({ success: true, data: status });
|
||||||
@@ -74,10 +100,16 @@ const agentCoreRouter = new Hono()
|
|||||||
|
|
||||||
const usage = await getTokenBalance(orgId);
|
const usage = await getTokenBalance(orgId);
|
||||||
|
|
||||||
|
const tutor =
|
||||||
|
role === AgentRole.STUDENT
|
||||||
|
? await getStudentTutorStatus(orgId, courseId, user.id)
|
||||||
|
: { enabled: true, cap: null, capRemaining: null, enforced: false };
|
||||||
|
|
||||||
const status: AgentStatus = {
|
const status: AgentStatus = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
role,
|
role,
|
||||||
usage
|
usage,
|
||||||
|
tutor
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json({ success: true, data: status });
|
return c.json({ success: true, data: status });
|
||||||
@@ -187,6 +219,67 @@ const agentCoreRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to fetch usage stats');
|
return handleError(c, error, 'Failed to fetch usage stats');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.get(
|
||||||
|
'/tutor-usage/leaderboard',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('query', ZTutorUsageQuery),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { period, search, sort, page, limit } = c.req.valid('query');
|
||||||
|
const data = await getTutorLearnerLeaderboard(orgId, { period, search, sort, page, limit });
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch learner leaderboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/tutor-usage/summary',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator(
|
||||||
|
'query',
|
||||||
|
ZTutorUsagePeriod.optional()
|
||||||
|
.transform((v) => v ?? 'current')
|
||||||
|
.pipe(ZTutorUsagePeriod)
|
||||||
|
.or(ZTutorUsagePeriod)
|
||||||
|
.transform((v) => ({ period: v }))
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const period = c.req.query('period');
|
||||||
|
const parsed = ZTutorUsagePeriod.safeParse(period);
|
||||||
|
const data = await getTutorCapStatusSummary(orgId, parsed.success ? parsed.data : 'current');
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch tutor cap summary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/tutor-usage/:userId',
|
||||||
|
authMiddleware,
|
||||||
|
orgAdminMiddleware,
|
||||||
|
zValidator('param', ZTutorUsageUserParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { userId } = c.req.valid('param');
|
||||||
|
const period = c.req.query('period');
|
||||||
|
const parsed = ZTutorUsagePeriod.safeParse(period);
|
||||||
|
const data = await getTutorLearnerDetail(orgId, userId, parsed.success ? parsed.data : 'current');
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch learner detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
|
.post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
@@ -233,6 +326,59 @@ const agentCoreRouter = new Hono()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
'/generate-text',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('json', ZAgentGenerateTextBody),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { prompt, tone, format, context, courseId } = c.req.valid('json');
|
||||||
|
|
||||||
|
const providerConfig = pickAnyConfiguredProvider();
|
||||||
|
|
||||||
|
if (!providerConfig) {
|
||||||
|
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
await enforceTokenBalance(orgId);
|
||||||
|
|
||||||
|
const { text, usage, modelName } = await generateFieldText(prompt, tone, format, context, providerConfig);
|
||||||
|
|
||||||
|
if (courseId) {
|
||||||
|
await recordTokenUsage(orgId, user.id, courseId, usage, modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: { text } });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to generate text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post('/summarize', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentSummarizeBody), async (c) => {
|
||||||
|
try {
|
||||||
|
const { messages, courseId } = c.req.valid('json');
|
||||||
|
const user = c.get('user')!;
|
||||||
|
|
||||||
|
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||||
|
|
||||||
|
if (!isTeamMember) {
|
||||||
|
throw new AppError(
|
||||||
|
'You must be a course team member to summarize conversations',
|
||||||
|
'NOT_COURSE_TEAM_MEMBER',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await summarizeConversation({ messages });
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: { summary } });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to summarize conversation');
|
||||||
|
}
|
||||||
|
})
|
||||||
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
|
.post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => {
|
||||||
const user = c.get('user')!;
|
const user = c.get('user')!;
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
@@ -240,7 +386,14 @@ const agentCoreRouter = new Hono()
|
|||||||
try {
|
try {
|
||||||
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
|
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
|
||||||
|
|
||||||
const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID;
|
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||||
|
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||||
|
|
||||||
|
// Students always use the platform default; admins can pick. Forcing the
|
||||||
|
// default avoids exposing premium models to learners and keeps per-message
|
||||||
|
// cost predictable for fair-use accounting.
|
||||||
|
const modelId =
|
||||||
|
role === AgentRole.STUDENT ? DEFAULT_PICKER_MODEL_ID : (requestedModel ?? DEFAULT_PICKER_MODEL_ID);
|
||||||
const modelDescriptor = AGENT_MODELS[modelId];
|
const modelDescriptor = AGENT_MODELS[modelId];
|
||||||
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
|
const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider);
|
||||||
|
|
||||||
@@ -250,13 +403,6 @@ const agentCoreRouter = new Hono()
|
|||||||
|
|
||||||
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
|
const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId };
|
||||||
|
|
||||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
|
||||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
|
||||||
|
|
||||||
if (role === AgentRole.STUDENT) {
|
|
||||||
throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [courseRow] = await db
|
const [courseRow] = await db
|
||||||
.select({
|
.select({
|
||||||
title: schema.course.title,
|
title: schema.course.title,
|
||||||
@@ -276,7 +422,7 @@ const agentCoreRouter = new Hono()
|
|||||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!modelDescriptor.isFree) {
|
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
|
||||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||||
|
|
||||||
if (!isPaid) {
|
if (!isPaid) {
|
||||||
@@ -284,7 +430,14 @@ const agentCoreRouter = new Hono()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
|
||||||
|
// Teachers continue to use the existing pool-only check.
|
||||||
|
const studentPolicy =
|
||||||
|
role === AgentRole.STUDENT ? await enforceStudentTutorPolicy(orgId, courseId, user.id) : null;
|
||||||
|
|
||||||
|
if (role === AgentRole.TEACHER) {
|
||||||
await enforceTokenBalance(orgId);
|
await enforceTokenBalance(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
const documentIds = collectDocumentIds(messages, context?.documentId);
|
const documentIds = collectDocumentIds(messages, context?.documentId);
|
||||||
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
|
const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined;
|
||||||
@@ -348,38 +501,98 @@ const agentCoreRouter = new Hono()
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const model = createModel(providerConfig);
|
const model = createModel(providerConfig);
|
||||||
const approvedPlan = getLatestImplementationPlan(messages);
|
const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined;
|
||||||
const systemPrompt = [
|
const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined;
|
||||||
buildSystemPrompt(agentContext),
|
const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined;
|
||||||
|
|
||||||
|
// Stable across requests — safe to cache as a long-lived Anthropic prefix.
|
||||||
|
// Volatile per-request context (lesson/exercise/document/section count/
|
||||||
|
// approved plan/active template) is sent as a user-turn message instead so
|
||||||
|
// it doesn't invalidate the tools+system cache.
|
||||||
|
const systemPrompt = buildSystemPrompt(agentContext, {
|
||||||
|
tutorSettings: studentPolicy?.settings
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextMessageText = buildContextMessage(agentContext, {
|
||||||
|
template: activeTemplate,
|
||||||
approvedPlan
|
approvedPlan
|
||||||
? [
|
});
|
||||||
'',
|
|
||||||
'The latest user message approved a final course plan for immediate execution.',
|
const agentTools =
|
||||||
'Implement that exact plan directly without asking the user to restate it.',
|
role === AgentRole.STUDENT
|
||||||
'Treat this approved plan as the canonical source if it differs from any earlier draft.',
|
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
||||||
`Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}`
|
: buildAgentTools(orgId, user.id, courseId, messages);
|
||||||
].join('\n')
|
|
||||||
: ''
|
|
||||||
].join('\n');
|
|
||||||
const agentTools = buildAgentTools(orgId, user.id, courseId);
|
|
||||||
|
|
||||||
const trimmedMessages = trimMessageHistory(messages);
|
const trimmedMessages = trimMessageHistory(messages);
|
||||||
const modelMessages = await convertToModelMessages(trimmedMessages);
|
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
|
||||||
let completedStepCount = 0;
|
let completedStepCount = 0;
|
||||||
let finishReason: string | undefined;
|
let finishReason: string | undefined;
|
||||||
|
|
||||||
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
|
const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC;
|
||||||
|
|
||||||
|
// 1h TTL keeps the prefix warm across tool-execution gaps in long agent
|
||||||
|
// runs. Break-even is 3 requests within the hour; well under most plan-
|
||||||
|
// execution loops.
|
||||||
const systemContent = isAnthropic
|
const systemContent = isAnthropic
|
||||||
? {
|
? {
|
||||||
role: 'system' as const,
|
role: 'system' as const,
|
||||||
content: systemPrompt,
|
content: systemPrompt,
|
||||||
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
providerOptions: {
|
||||||
|
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: systemPrompt;
|
: systemPrompt;
|
||||||
|
|
||||||
|
// Prepend volatile context as a user-turn message so the stable system +
|
||||||
|
// tools prefix stays cacheable even when the teacher navigates to a
|
||||||
|
// different lesson or the agent creates sections mid-run.
|
||||||
|
const modelMessages =
|
||||||
|
contextMessageText.length > 0
|
||||||
|
? [
|
||||||
|
{ role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] },
|
||||||
|
...convertedMessages
|
||||||
|
]
|
||||||
|
: convertedMessages;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[agent.chat] modelMessages →',
|
||||||
|
JSON.stringify(
|
||||||
|
modelMessages.map((m, i) => ({
|
||||||
|
i,
|
||||||
|
role: m.role,
|
||||||
|
parts: Array.isArray(m.content)
|
||||||
|
? m.content.map((p: any) => ({
|
||||||
|
type: p.type,
|
||||||
|
toolCallId: p.toolCallId,
|
||||||
|
toolName: p.toolName,
|
||||||
|
textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined
|
||||||
|
}))
|
||||||
|
: typeof m.content === 'string'
|
||||||
|
? m.content.slice(0, 80)
|
||||||
|
: m.content
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the growing conversation prefix: each turn reads the prior
|
||||||
|
// transcript at ~0.1x cost instead of reprocessing it at full price.
|
||||||
|
if (isAnthropic && modelMessages.length > 0) {
|
||||||
|
const lastMessage = modelMessages[modelMessages.length - 1];
|
||||||
|
const existingAnthropic = (lastMessage.providerOptions?.anthropic as Record<string, unknown> | undefined) ?? {};
|
||||||
|
lastMessage.providerOptions = {
|
||||||
|
...(lastMessage.providerOptions ?? {}),
|
||||||
|
anthropic: {
|
||||||
|
...existingAnthropic,
|
||||||
|
cacheControl: { type: 'ephemeral', ttl: '1h' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
maxRetries: 0,
|
maxRetries: 2,
|
||||||
system: systemContent,
|
system: systemContent,
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
tools: agentTools,
|
tools: agentTools,
|
||||||
@@ -394,6 +607,21 @@ const agentCoreRouter = new Hono()
|
|||||||
const inputTokens = totalUsage?.inputTokens ?? 0;
|
const inputTokens = totalUsage?.inputTokens ?? 0;
|
||||||
const outputTokens = totalUsage?.outputTokens ?? 0;
|
const outputTokens = totalUsage?.outputTokens ?? 0;
|
||||||
|
|
||||||
|
// Cache hit/miss visibility. If cacheRead stays 0 across repeated
|
||||||
|
// turns of the same conversation, a silent invalidator is leaking
|
||||||
|
// into the cached prefix — audit system prompt and tool definitions.
|
||||||
|
if (isAnthropic) {
|
||||||
|
const details = totalUsage?.inputTokenDetails;
|
||||||
|
const cacheRead = details?.cacheReadTokens ?? 0;
|
||||||
|
const cacheWrite = details?.cacheWriteTokens ?? 0;
|
||||||
|
const uncached = details?.noCacheTokens ?? inputTokens;
|
||||||
|
const total = uncached + cacheRead + cacheWrite;
|
||||||
|
const hitRate = total > 0 ? Math.round((cacheRead / total) * 100) : 0;
|
||||||
|
console.log(
|
||||||
|
`[agent.chat] cache hit=${hitRate}% read=${cacheRead} write=${cacheWrite} uncached=${uncached} output=${outputTokens}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (totalUsage) {
|
if (totalUsage) {
|
||||||
await recordTokenUsage(
|
await recordTokenUsage(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -408,6 +636,10 @@ const agentCoreRouter = new Hono()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === AgentRole.STUDENT) {
|
||||||
|
await incrementStudentTutorCount(orgId, user.id, courseId);
|
||||||
|
}
|
||||||
|
|
||||||
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
|
trackAgentEvent(AgentEvent.CHAT_COMPLETED, {
|
||||||
orgId,
|
orgId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ZAgentConversationParam,
|
ZAgentConversationParam,
|
||||||
ZAgentConversationCreateBody,
|
ZAgentConversationCreateBody,
|
||||||
ZAgentHistorySaveBody,
|
ZAgentHistorySaveBody,
|
||||||
|
ZAgentHistoryRenameBody,
|
||||||
ZAgentHistoryDeleteParam,
|
ZAgentHistoryDeleteParam,
|
||||||
ZAgentGenerateTitleParam,
|
ZAgentGenerateTitleParam,
|
||||||
ZAgentGenerateTitleBody
|
ZAgentGenerateTitleBody
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
deleteChatConversation,
|
deleteChatConversation,
|
||||||
updateConversationTitle
|
updateConversationTitle
|
||||||
} from '@api/services/agent/chat-history';
|
} from '@api/services/agent/chat-history';
|
||||||
|
import { compactConversation } from '@api/services/agent/compact';
|
||||||
import { generateConversationTitle } from '@api/services/agent/title-generation';
|
import { generateConversationTitle } from '@api/services/agent/title-generation';
|
||||||
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||||
|
|
||||||
@@ -110,6 +112,37 @@ export const agentHistoryRouter = new Hono()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /agent/history/:conversationId
|
||||||
|
* Rename a conversation without re-uploading messages.
|
||||||
|
*/
|
||||||
|
.patch(
|
||||||
|
'/:conversationId',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('param', ZAgentConversationParam),
|
||||||
|
zValidator('json', ZAgentHistoryRenameBody),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const { conversationId } = c.req.valid('param');
|
||||||
|
const { title } = c.req.valid('json');
|
||||||
|
|
||||||
|
const conversation = await getChatConversation(conversationId, user.id);
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
return c.json({ success: false as const, error: 'Conversation not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateConversationTitle(conversationId, user.id, title);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: { id: conversationId, title } });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to rename conversation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /agent/history/:conversationId
|
* DELETE /agent/history/:conversationId
|
||||||
* Delete a conversation.
|
* Delete a conversation.
|
||||||
@@ -133,6 +166,28 @@ export const agentHistoryRouter = new Hono()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /agent/history/:conversationId/compact
|
||||||
|
* Summarize and replace persisted messages with a single seed message.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:conversationId/compact',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('param', ZAgentConversationParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const { conversationId } = c.req.valid('param');
|
||||||
|
const data = await compactConversation(conversationId, user.id);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to compact conversation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /agent/history/:conversationId/generate-title
|
* POST /agent/history/:conversationId/generate-title
|
||||||
* Generate a short title from the first user message using a cheap model.
|
* Generate a short title from the first user message using a cheap model.
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
ZCertificateDownload,
|
ZCertificateDownloadRequest,
|
||||||
ZCourseClone,
|
ZCourseClone,
|
||||||
ZCourseCloneParam,
|
ZCourseCloneParam,
|
||||||
ZCourseCreate,
|
ZCourseCreate,
|
||||||
@@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag';
|
|||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { attendanceRouter } from '@api/routes/course/attendance';
|
import { attendanceRouter } from '@api/routes/course/attendance';
|
||||||
|
import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
import { cloneCourse } from '@api/services/course/clone';
|
import { cloneCourse } from '@api/services/course/clone';
|
||||||
@@ -43,9 +44,11 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter';
|
|||||||
import { enrollInCourse } from '@api/services/course/invite';
|
import { enrollInCourse } from '@api/services/course/invite';
|
||||||
import { exerciseRouter } from '@api/routes/course/exercise';
|
import { exerciseRouter } from '@api/routes/course/exercise';
|
||||||
import { extractClientIp } from '@api/utils/redis/key-generators';
|
import { extractClientIp } from '@api/utils/redis/key-generators';
|
||||||
import { generateCertificate } from '@api/utils/certificate';
|
import { generateCertificatePdf, generateCertificatePng } from '@api/utils/certificate';
|
||||||
|
import { assembleCertificateRender, assembleOwnerPreviewRender } from '@api/services/course/certificate';
|
||||||
|
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||||
import { generateCoursePdf } from '@api/utils/course';
|
import { generateCoursePdf } from '@api/utils/course';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { AppError, ErrorCodes, handleError } from '@api/utils/errors';
|
||||||
import { invitesRouter } from '@api/routes/course/invite';
|
import { invitesRouter } from '@api/routes/course/invite';
|
||||||
import { katexRouter } from '@api/routes/course/katex';
|
import { katexRouter } from '@api/routes/course/katex';
|
||||||
import { lessonRouter } from '@api/routes/course/lesson';
|
import { lessonRouter } from '@api/routes/course/lesson';
|
||||||
@@ -62,6 +65,36 @@ import { submissionRouter } from '@api/routes/course/submission';
|
|||||||
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
|
function slugifyForFilename(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^a-zA-Z0-9 ]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.slice(0, 60) || 'certificate'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCertificateInput(
|
||||||
|
courseId: string,
|
||||||
|
userId: string,
|
||||||
|
body: import('@cio/utils/validation/course').TCertificateDownloadRequest
|
||||||
|
) {
|
||||||
|
if (body.previewMode) {
|
||||||
|
const isTeam = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
|
||||||
|
if (!isTeam) {
|
||||||
|
throw new AppError('Only course team members can preview certificate designs', ErrorCodes.UNAUTHORIZED, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembleOwnerPreviewRender(courseId, userId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertCertificateDownloadAllowed(courseId, userId);
|
||||||
|
|
||||||
|
return assembleCertificateRender(courseId, body);
|
||||||
|
}
|
||||||
|
|
||||||
const enrollRateLimit = createRateLimiter({
|
const enrollRateLimit = createRateLimiter({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
maxRequests: 20,
|
maxRequests: 20,
|
||||||
@@ -454,27 +487,67 @@ export const courseRouter = new Hono()
|
|||||||
authMiddleware,
|
authMiddleware,
|
||||||
courseMemberMiddleware,
|
courseMemberMiddleware,
|
||||||
zValidator('param', ZCourseDownloadParam),
|
zValidator('param', ZCourseDownloadParam),
|
||||||
zValidator('json', ZCertificateDownload),
|
zValidator('json', ZCertificateDownloadRequest),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
try {
|
||||||
const { courseId } = c.req.valid('param');
|
const { courseId } = c.req.valid('param');
|
||||||
const user = c.get('user')!;
|
const user = c.get('user')!;
|
||||||
await assertCertificateDownloadAllowed(courseId, user.id);
|
const body = c.req.valid('json');
|
||||||
|
|
||||||
const validatedData = c.req.valid('json');
|
const input = await loadCertificateInput(courseId, user.id, body);
|
||||||
|
const buffer = await generateCertificatePdf(input);
|
||||||
const result = await generateCertificate(validatedData);
|
|
||||||
|
|
||||||
c.header('Content-Type', 'application/pdf');
|
c.header('Content-Type', 'application/pdf');
|
||||||
c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`);
|
c.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.pdf"`
|
||||||
|
);
|
||||||
|
|
||||||
return c.body(
|
return c.body(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
controller.enqueue(result);
|
controller.enqueue(buffer);
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to download certificate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/:courseId/download/certificate/png',
|
||||||
|
authMiddleware,
|
||||||
|
courseMemberMiddleware,
|
||||||
|
zValidator('param', ZCourseDownloadParam),
|
||||||
|
zValidator('json', ZCertificateDownloadRequest),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = c.req.valid('param');
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const body = c.req.valid('json');
|
||||||
|
|
||||||
|
const input = await loadCertificateInput(courseId, user.id, body);
|
||||||
|
const buffer = await generateCertificatePng(input);
|
||||||
|
|
||||||
|
c.header('Content-Type', 'image/png');
|
||||||
|
c.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.png"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.body(
|
||||||
|
new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(buffer);
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to download certificate image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -542,4 +615,5 @@ export const courseRouter = new Hono()
|
|||||||
.route('/:courseId/newsfeed', newsfeedRouter)
|
.route('/:courseId/newsfeed', newsfeedRouter)
|
||||||
.route('/:courseId/members', membersRouter)
|
.route('/:courseId/members', membersRouter)
|
||||||
.route('/:courseId/invites', invitesRouter)
|
.route('/:courseId/invites', invitesRouter)
|
||||||
|
.route('/:courseId/ai-tutor', courseAiTutorRouter)
|
||||||
.route('/presign', presignRouter);
|
.route('/presign', presignRouter);
|
||||||
|
|||||||
@@ -1,13 +1,44 @@
|
|||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash';
|
import {
|
||||||
|
ZDashAnalyticsRange,
|
||||||
|
ZDashComplianceOverview,
|
||||||
|
ZDashCourseFunnel,
|
||||||
|
ZDashStats,
|
||||||
|
ZIngestBatch,
|
||||||
|
ZLoginActivity
|
||||||
|
} from '@cio/utils/validation/dash';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
|
import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash';
|
||||||
|
import { getOrgComplianceOverview } from '@api/services/course/compliance';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import {
|
||||||
|
getCountryBreakdown,
|
||||||
|
getCourseFunnel,
|
||||||
|
getLandingStats,
|
||||||
|
getPopularTypes,
|
||||||
|
ingestEventBatch
|
||||||
|
} from '@api/services/analytics';
|
||||||
|
|
||||||
export const dashAnalyticsRouter = new Hono()
|
export const dashAnalyticsRouter = new Hono()
|
||||||
|
.post('/track', zValidator('json', ZIngestBatch), async (c) => {
|
||||||
|
try {
|
||||||
|
const batch = c.req.valid('json');
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
const result = await ingestEventBatch(batch, {
|
||||||
|
country: c.req.header('cf-ipcountry') ?? c.req.header('CF-IPCountry') ?? null,
|
||||||
|
userAgent: c.req.header('user-agent') ?? null,
|
||||||
|
userId: user?.id ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to ingest analytics events');
|
||||||
|
}
|
||||||
|
})
|
||||||
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
|
.get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => {
|
||||||
try {
|
try {
|
||||||
const { orgId, siteName } = c.req.valid('query');
|
const { orgId, siteName } = c.req.valid('query');
|
||||||
@@ -39,4 +70,70 @@ export const dashAnalyticsRouter = new Hono()
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to load login streak');
|
return handleError(c, error, 'Failed to load login streak');
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.get('/landing-stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId, days } = c.req.valid('query');
|
||||||
|
const bust = c.req.query('bust') === '1';
|
||||||
|
const result = await getLandingStats(orgId, days, bust);
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load landing stats');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
'/country-breakdown',
|
||||||
|
authMiddleware,
|
||||||
|
orgMemberMiddleware,
|
||||||
|
zValidator('query', ZDashAnalyticsRange),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId, days } = c.req.valid('query');
|
||||||
|
const bust = c.req.query('bust') === '1';
|
||||||
|
const result = await getCountryBreakdown(orgId, days, bust);
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load country breakdown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get('/course-funnel', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashCourseFunnel), async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId, days, courseId } = c.req.valid('query');
|
||||||
|
const bust = c.req.query('bust') === '1';
|
||||||
|
const result = await getCourseFunnel(orgId, days, courseId, bust);
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load course funnel');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get('/popular-types', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId, days } = c.req.valid('query');
|
||||||
|
const bust = c.req.query('bust') === '1';
|
||||||
|
const result = await getPopularTypes(orgId, days, bust);
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load popular course types');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
'/compliance-overview',
|
||||||
|
authMiddleware,
|
||||||
|
orgAdminMiddleware,
|
||||||
|
zValidator('query', ZDashComplianceOverview),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { orgId } = c.req.valid('query');
|
||||||
|
const result = await getOrgComplianceOverview(orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load compliance overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance';
|
||||||
|
import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||||
@@ -34,4 +35,32 @@ export const internalComplianceRouter = new Hono()
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to run compliance reminder scan');
|
return handleError(c, error, 'Failed to run compliance reminder scan');
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* POST /internal/compliance/evaluate-program-goals
|
||||||
|
* Daily sweep: re-evaluate every active program goal and refresh per-learner
|
||||||
|
* `program_goal_assignment` rows.
|
||||||
|
*/
|
||||||
|
.post('/evaluate-program-goals', apiKeyMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const data = await runProgramGoalEvaluationSweep();
|
||||||
|
|
||||||
|
return c.json({ success: true, data }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to run program goal evaluation sweep');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* POST /internal/compliance/send-program-goal-reminders
|
||||||
|
* Sends learner reminder emails for upcoming/overdue program goals using
|
||||||
|
* each goal's `reminderDaysBefore` cadence.
|
||||||
|
*/
|
||||||
|
.post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const data = await runProgramGoalReminderScan();
|
||||||
|
|
||||||
|
return c.json({ success: true, data }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to run program goal reminder scan');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { Hono } from '@api/utils/hono';
|
||||||
import { ZDeliverEmail } from '@cio/utils/validation/mail';
|
import { ZDeliverEmail } from '@cio/utils/validation/mail';
|
||||||
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||||
import { deliverEmail } from '@cio/email';
|
import { enqueueRawEmail } from '@api/services/jobs';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public mail endpoint — accepts a list of raw subject/content emails and
|
||||||
|
* enqueues them on the BullMQ `emails` queue via the transactional outbox.
|
||||||
|
* Returns 202 with the outbox + delivery ids so callers can poll status if
|
||||||
|
* needed.
|
||||||
|
*/
|
||||||
export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
|
export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => {
|
||||||
const validatedData = c.req.valid('json');
|
const validatedData = c.req.valid('json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await deliverEmail(validatedData);
|
const enqueued = await Promise.all(
|
||||||
|
validatedData.map((emailItem) =>
|
||||||
|
enqueueRawEmail({
|
||||||
|
from: emailItem.from,
|
||||||
|
to: emailItem.to,
|
||||||
|
subject: emailItem.subject,
|
||||||
|
content: emailItem.content,
|
||||||
|
replyTo: emailItem.replyTo
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobIds = enqueued.flatMap((entry) => entry.jobIds);
|
||||||
|
|
||||||
const hasErrors = results.some((result) => !result.success);
|
|
||||||
if (hasErrors) {
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: true,
|
||||||
error: 'Some emails failed to send',
|
data: {
|
||||||
code: 'EMAIL_SEND_PARTIAL_FAILURE',
|
accepted: jobIds.length,
|
||||||
details: results
|
jobIds
|
||||||
},
|
|
||||||
500
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return c.json({ success: true, data: results }, 200);
|
202
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED');
|
return handleError(c, error, 'Failed to enqueue email send request', 'EMAIL_SEND_FAILED');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from '@api/utils/hono';
|
||||||
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
|
import { handleError } from '@api/utils/errors';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { ZAiTutorSettingsUpdate } from '@cio/utils/validation/agent';
|
||||||
|
|
||||||
|
import { getResolvedOrgAiTutorSettings, updateOrgAiTutorSettingsService } from '@api/services/agent/tutor-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org-level AI tutor configuration.
|
||||||
|
*
|
||||||
|
* GET /organization/ai-tutor — any org member can read the resolved settings.
|
||||||
|
* PUT /organization/ai-tutor — org admin only; merges the patch into stored settings.
|
||||||
|
*/
|
||||||
|
export const organizationAiTutorRouter = new Hono()
|
||||||
|
.get('/', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const settings = await getResolvedOrgAiTutorSettings(orgId);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: settings });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch AI tutor settings');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.put('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZAiTutorSettingsUpdate), async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const patch = c.req.valid('json');
|
||||||
|
const updated = await updateOrgAiTutorSettingsService(orgId, patch);
|
||||||
|
|
||||||
|
return c.json({ success: true as const, data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to update AI tutor settings');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getAssetService,
|
getAssetService,
|
||||||
getAssetUsageGraphService,
|
getAssetUsageGraphService,
|
||||||
getOrganizationAssetStorageService,
|
getOrganizationAssetStorageService,
|
||||||
|
getTranscriptForOrganizationAssetService,
|
||||||
getYouTubeMetadataService,
|
getYouTubeMetadataService,
|
||||||
listOrganizationAssetsService,
|
listOrganizationAssetsService,
|
||||||
updateAssetService
|
updateAssetService
|
||||||
@@ -150,6 +151,27 @@ export const assetsRouter = new Hono()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* GET /organization/assets/:assetId/transcript
|
||||||
|
* Whisper transcript + presigned VTT URL for captions (or null if none).
|
||||||
|
*/
|
||||||
|
.get('/:assetId/transcript', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
const { assetId } = c.req.valid('param');
|
||||||
|
const data = await getTranscriptForOrganizationAssetService(orgId, assetId);
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to fetch transcript');
|
||||||
|
}
|
||||||
|
})
|
||||||
/**
|
/**
|
||||||
* GET /organization/assets/:assetId
|
* GET /organization/assets/:assetId
|
||||||
* Get a single asset by id
|
* Get a single asset by id
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono';
|
|||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { TOrganization } from '@db/types';
|
import { TOrganization } from '@db/types';
|
||||||
import { assetsRouter } from '@api/routes/organization/assets';
|
import { assetsRouter } from '@api/routes/organization/assets';
|
||||||
|
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||||
@@ -670,4 +671,5 @@ export const organizationRouter = new Hono()
|
|||||||
.route('/tags', tagsRouter)
|
.route('/tags', tagsRouter)
|
||||||
.route('/widgets', widgetsRouter)
|
.route('/widgets', widgetsRouter)
|
||||||
.route('/assets', assetsRouter)
|
.route('/assets', assetsRouter)
|
||||||
|
.route('/ai-tutor', organizationAiTutorRouter)
|
||||||
.route('/:orgId/quiz', quizRouter);
|
.route('/:orgId/quiz', quizRouter);
|
||||||
|
|||||||
@@ -3,19 +3,12 @@ import { authMiddleware } from '@api/middlewares/auth';
|
|||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||||
import {
|
import { ZCreateWidget, ZRollbackWidget, ZUpdateWidget, ZWidgetIdParams } from '@cio/utils/validation/widget';
|
||||||
ZCreateWidget,
|
|
||||||
ZPreviewWidget,
|
|
||||||
ZRollbackWidget,
|
|
||||||
ZUpdateWidget,
|
|
||||||
ZWidgetIdParams
|
|
||||||
} from '@cio/utils/validation/widget';
|
|
||||||
import {
|
import {
|
||||||
createOrganizationWidget,
|
createOrganizationWidget,
|
||||||
deleteOrganizationWidget,
|
deleteOrganizationWidget,
|
||||||
getOrganizationWidgetDetail,
|
getOrganizationWidgetDetail,
|
||||||
listOrganizationWidgets,
|
listOrganizationWidgets,
|
||||||
previewOrganizationWidget,
|
|
||||||
publishOrganizationWidget,
|
publishOrganizationWidget,
|
||||||
rollbackOrganizationWidget,
|
rollbackOrganizationWidget,
|
||||||
updateOrganizationWidget
|
updateOrganizationWidget
|
||||||
@@ -87,25 +80,6 @@ export const widgetsRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to delete widget');
|
return handleError(c, error, 'Failed to delete widget');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post(
|
|
||||||
'/:widgetId/preview',
|
|
||||||
authMiddleware,
|
|
||||||
orgTeamMemberMiddleware,
|
|
||||||
zValidator('param', ZWidgetIdParams),
|
|
||||||
zValidator('json', ZPreviewWidget),
|
|
||||||
async (c) => {
|
|
||||||
try {
|
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
|
||||||
const { widgetId } = c.req.valid('param');
|
|
||||||
const data = c.req.valid('json');
|
|
||||||
const payload = await previewOrganizationWidget(orgId, widgetId, data);
|
|
||||||
|
|
||||||
return c.json({ success: true, data: payload });
|
|
||||||
} catch (error) {
|
|
||||||
return handleError(c, error, 'Failed to build widget preview');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
|
.post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => {
|
||||||
try {
|
try {
|
||||||
const orgId = c.req.header('cio-org-id')!;
|
const orgId = c.req.header('cio-org-id')!;
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import * as z from 'zod';
|
|||||||
import {
|
import {
|
||||||
ZAddCourseToProgram,
|
ZAddCourseToProgram,
|
||||||
ZAddProgramMembers,
|
ZAddProgramMembers,
|
||||||
|
ZAssignExistingStudentsToProgram,
|
||||||
ZCreateProgram,
|
ZCreateProgram,
|
||||||
|
ZCreateProgramGoal,
|
||||||
ZCreateProgramNewsfeed,
|
ZCreateProgramNewsfeed,
|
||||||
ZCreateProgramNewsfeedComment,
|
ZCreateProgramNewsfeedComment,
|
||||||
|
ZInviteStudentsToProgram,
|
||||||
ZUpdateProgram,
|
ZUpdateProgram,
|
||||||
|
ZUpdateProgramGoal,
|
||||||
ZUpdateProgramMember,
|
ZUpdateProgramMember,
|
||||||
ZUpdateProgramNewsfeed,
|
ZUpdateProgramNewsfeed,
|
||||||
ZUpdateProgramReaction
|
ZUpdateProgramReaction
|
||||||
@@ -34,9 +38,23 @@ import {
|
|||||||
updateProgramNewsfeedReactionService,
|
updateProgramNewsfeedReactionService,
|
||||||
updateProgramNewsfeedService
|
updateProgramNewsfeedService
|
||||||
} from '@api/services/program/program';
|
} from '@api/services/program/program';
|
||||||
|
import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite';
|
||||||
|
import {
|
||||||
|
archiveGoal,
|
||||||
|
createGoal,
|
||||||
|
evaluateGoal,
|
||||||
|
evaluateProgramGoals,
|
||||||
|
getGoal,
|
||||||
|
getMyGoals,
|
||||||
|
getOrgGoalsOverview,
|
||||||
|
listGoals,
|
||||||
|
removeGoal,
|
||||||
|
updateGoal
|
||||||
|
} from '@api/services/program/goal';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
|
import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member';
|
||||||
import { programMemberMiddleware } from '@api/middlewares/program-member';
|
import { programMemberMiddleware } from '@api/middlewares/program-member';
|
||||||
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
|
import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member';
|
||||||
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
|
import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team';
|
||||||
@@ -52,6 +70,7 @@ const ZCommentParam = z.object({
|
|||||||
feedId: z.string().uuid(),
|
feedId: z.string().uuid(),
|
||||||
commentId: z.coerce.number().int()
|
commentId: z.coerce.number().int()
|
||||||
});
|
});
|
||||||
|
const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() });
|
||||||
const ZListQuery = z.object({
|
const ZListQuery = z.object({
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
limit: z.coerce.number().int().min(1).max(50).default(10)
|
limit: z.coerce.number().int().min(1).max(50).default(10)
|
||||||
@@ -247,6 +266,56 @@ export const programRouter = new Hono()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Invite ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/invite
|
||||||
|
* Invite new students to a program by CSV emails. Issues org invites
|
||||||
|
* pre-tagged with this program so accept auto-enrolls them.
|
||||||
|
* Gated by program-team-member middleware — Program ADMINs/TUTORs can
|
||||||
|
* invite into their own program without org-admin rights.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/invite',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZProgramParam),
|
||||||
|
zValidator('json', ZInviteStudentsToProgram),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const { programId } = c.req.valid('param');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const result = await inviteStudentsToProgram(programId, data, user.id);
|
||||||
|
return c.json({ success: true, data: result }, 201);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to invite students to program');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/invite/assign
|
||||||
|
* Assign existing org-audience student profiles to a program.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/invite/assign',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZProgramParam),
|
||||||
|
zValidator('json', ZAssignExistingStudentsToProgram),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { programId } = c.req.valid('param');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const result = await assignExistingStudentsToProgram(programId, data);
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to assign students to program');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ── Courses ───────────────────────────────────────────────────────────────
|
// ── Courses ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -484,4 +553,190 @@ export const programRouter = new Hono()
|
|||||||
return handleError(c, error, 'Failed to delete program newsfeed comment');
|
return handleError(c, error, 'Failed to delete program newsfeed comment');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Goals ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /program/my/goals
|
||||||
|
* Goal assignments for the current authenticated user across all their programs.
|
||||||
|
* Used by the LMS "Your Goals" widget.
|
||||||
|
*/
|
||||||
|
.get('/my/goals', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const goals = await getMyGoals(user.id);
|
||||||
|
return c.json({ success: true, data: goals }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load goals');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /program/goals/overview?organizationId=...
|
||||||
|
* Cross-program goal roll-up for the org owner. One entry per active goal
|
||||||
|
* with per-status learner counts, scoped to the org via header + query.
|
||||||
|
*/
|
||||||
|
.get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => {
|
||||||
|
try {
|
||||||
|
const { organizationId } = c.req.valid('query');
|
||||||
|
const overview = await getOrgGoalsOverview(organizationId);
|
||||||
|
return c.json({ success: true, data: overview }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to load goals overview');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /program/:programId/goals
|
||||||
|
*/
|
||||||
|
.get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => {
|
||||||
|
try {
|
||||||
|
const { programId } = c.req.valid('param');
|
||||||
|
const goals = await listGoals(programId);
|
||||||
|
return c.json({ success: true, data: goals }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to list program goals');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/goals
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/goals',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZProgramParam),
|
||||||
|
zValidator('json', ZCreateProgramGoal),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const user = c.get('user')!;
|
||||||
|
const { programId } = c.req.valid('param');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const goal = await createGoal(programId, user.id, data);
|
||||||
|
return c.json({ success: true, data: goal }, 201);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to create program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /program/:programId/goals/:goalId
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
'/:programId/goals/:goalId',
|
||||||
|
authMiddleware,
|
||||||
|
programMemberMiddleware,
|
||||||
|
zValidator('param', ZGoalParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { goalId } = c.req.valid('param');
|
||||||
|
const goal = await getGoal(goalId);
|
||||||
|
return c.json({ success: true, data: goal }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to get program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /program/:programId/goals/:goalId
|
||||||
|
*/
|
||||||
|
.put(
|
||||||
|
'/:programId/goals/:goalId',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZGoalParam),
|
||||||
|
zValidator('json', ZUpdateProgramGoal),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { goalId } = c.req.valid('param');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const goal = await updateGoal(goalId, data);
|
||||||
|
return c.json({ success: true, data: goal }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to update program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /program/:programId/goals/:goalId
|
||||||
|
* Hard delete (vs. archive). Use PUT with status=archived to preserve history.
|
||||||
|
*/
|
||||||
|
.delete(
|
||||||
|
'/:programId/goals/:goalId',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZGoalParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { goalId } = c.req.valid('param');
|
||||||
|
const goal = await removeGoal(goalId);
|
||||||
|
return c.json({ success: true, data: goal }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to delete program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/goals/:goalId/archive
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/goals/:goalId/archive',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZGoalParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { goalId } = c.req.valid('param');
|
||||||
|
const goal = await archiveGoal(goalId);
|
||||||
|
return c.json({ success: true, data: goal }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to archive program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/goals/:goalId/evaluate
|
||||||
|
* Force a re-evaluation of one goal's per-learner statuses.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/goals/:goalId/evaluate',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZGoalParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { goalId } = c.req.valid('param');
|
||||||
|
const result = await evaluateGoal(goalId);
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to evaluate program goal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /program/:programId/goals/evaluate-all
|
||||||
|
* Force a re-evaluation of every active goal in the program.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/:programId/goals/evaluate-all',
|
||||||
|
authMiddleware,
|
||||||
|
programTeamMemberMiddleware,
|
||||||
|
zValidator('param', ZProgramParam),
|
||||||
|
async (c) => {
|
||||||
|
try {
|
||||||
|
const { programId } = c.req.valid('param');
|
||||||
|
const result = await evaluateProgramGoals(programId);
|
||||||
|
return c.json({ success: true, data: result }, 200);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(c, error, 'Failed to evaluate program goals');
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { z } from 'zod';
|
||||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
import { CoursePlanSchema } from '@cio/ai-assistant';
|
||||||
|
import {
|
||||||
|
LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT,
|
||||||
|
LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT,
|
||||||
|
LANDING_PAGE_SECTION_HTML_AGENT_HINT
|
||||||
|
} from '@cio/ai-assistant/tools';
|
||||||
|
import { QUESTION_TYPE_REGISTRY } from '@cio/question-types';
|
||||||
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise';
|
||||||
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course';
|
||||||
|
|
||||||
@@ -56,7 +62,14 @@ export const updateContentParam = z.object({
|
|||||||
|
|
||||||
export const questionSchema = z.object({
|
export const questionSchema = z.object({
|
||||||
question: z.string().min(1),
|
question: z.string().min(1),
|
||||||
questionTypeId: z.number().int().min(1).max(14).default(1),
|
questionTypeId: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(QUESTION_TYPE_REGISTRY.length)
|
||||||
|
.describe(
|
||||||
|
'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.'
|
||||||
|
),
|
||||||
points: z.number().min(0).default(1),
|
points: z.number().min(0).default(1),
|
||||||
order: z.number().int().min(0),
|
order: z.number().int().min(0),
|
||||||
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
|
options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() }))
|
||||||
@@ -150,17 +163,36 @@ export const addQuestionsParam = z.object({
|
|||||||
questions: z.array(questionSchema)
|
questions: z.array(questionSchema)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateQuestionSettingsSchema = z
|
||||||
|
.object({
|
||||||
|
correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(),
|
||||||
|
tolerance: z.number().min(0).optional(),
|
||||||
|
correctAnswers: z.array(z.string().min(1)).optional(),
|
||||||
|
template: z.string().min(1).optional(),
|
||||||
|
maxStars: z.number().int().min(1).optional(),
|
||||||
|
caseSensitive: z.boolean().optional(),
|
||||||
|
distractors: z.array(z.string().min(1)).optional()
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.refine((settings) => Object.keys(settings).length > 0, {
|
||||||
|
message: 'Provide at least one settings field'
|
||||||
|
})
|
||||||
|
.describe(
|
||||||
|
'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.'
|
||||||
|
);
|
||||||
|
|
||||||
export const updateOptionSchema = z.object({
|
export const updateOptionSchema = z.object({
|
||||||
id: z.number().int().optional(),
|
id: z.number().int().optional(),
|
||||||
label: z.string().min(1).optional(),
|
label: z.string().min(1).optional(),
|
||||||
isCorrect: z.boolean().optional(),
|
isCorrect: z.boolean().optional(),
|
||||||
settings: z.record(z.string(), z.unknown()).optional()
|
settings: updateQuestionSettingsSchema.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateQuestionPatchSchema = z.object({
|
export const updateQuestionPatchSchema = z
|
||||||
|
.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
question: z.string().min(1).optional(),
|
question: z.string().min(1).optional(),
|
||||||
questionTypeId: z.number().int().min(1).max(14).optional(),
|
questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(),
|
||||||
points: z.number().min(0).optional(),
|
points: z.number().min(0).optional(),
|
||||||
order: z.number().int().min(0).optional(),
|
order: z.number().int().min(0).optional(),
|
||||||
exerciseSectionId: z
|
exerciseSectionId: z
|
||||||
@@ -171,9 +203,22 @@ export const updateQuestionPatchSchema = z.object({
|
|||||||
.describe(
|
.describe(
|
||||||
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.'
|
||||||
),
|
),
|
||||||
settings: z.record(z.string(), z.unknown()).optional(),
|
settings: updateQuestionSettingsSchema.optional(),
|
||||||
options: z.array(updateOptionSchema).optional()
|
options: z.array(updateOptionSchema).optional()
|
||||||
});
|
})
|
||||||
|
.refine(
|
||||||
|
(patch) =>
|
||||||
|
patch.question !== undefined ||
|
||||||
|
patch.questionTypeId !== undefined ||
|
||||||
|
patch.points !== undefined ||
|
||||||
|
patch.order !== undefined ||
|
||||||
|
patch.exerciseSectionId !== undefined ||
|
||||||
|
patch.settings !== undefined ||
|
||||||
|
patch.options !== undefined,
|
||||||
|
{
|
||||||
|
message: 'Provide at least one field to update besides id'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const updateQuestionsParam = z.object({
|
export const updateQuestionsParam = z.object({
|
||||||
exerciseId: z.string(),
|
exerciseId: z.string(),
|
||||||
@@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({
|
|||||||
});
|
});
|
||||||
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
export const coursePlanParam = z.object({ plan: CoursePlanSchema });
|
||||||
|
|
||||||
|
const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||||
|
|
||||||
|
const agentTemplateFormFieldBase = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
placeholder: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentTemplateFormFieldParam = z.discriminatedUnion('type', [
|
||||||
|
agentTemplateFormFieldBase.extend({ type: z.literal('text') }),
|
||||||
|
agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }),
|
||||||
|
agentTemplateFormFieldBase.extend({ type: z.literal('url') }),
|
||||||
|
agentTemplateFormFieldBase.extend({
|
||||||
|
type: z.literal('select'),
|
||||||
|
options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1)
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const askTemplateQuestionsParam = z.object({
|
||||||
|
templateId: courseTemplateIdParam,
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
fields: z.array(agentTemplateFormFieldParam).min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchDocumentationUrlParam = z.object({
|
||||||
|
url: z.string().url()
|
||||||
|
});
|
||||||
|
|
||||||
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
// Gemini's tool-schema validator only accepts string enums, so numeric/boolean
|
||||||
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
// `z.literal` values must be relaxed to plain types in the schemas the model sees.
|
||||||
export const agentLessonTabsOrder = z.array(
|
export const agentLessonTabsOrder = z.array(
|
||||||
@@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({
|
||||||
lessonTabsOrder: agentLessonTabsOrder.optional()
|
lessonTabsOrder: agentLessonTabsOrder.optional(),
|
||||||
|
requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||||
|
description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT),
|
||||||
|
goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||||
|
instructor: z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
coursesNo: z.number().optional(),
|
||||||
|
description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||||
|
imgUrl: z.string().optional()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
});
|
});
|
||||||
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({
|
||||||
|
title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'),
|
||||||
|
description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT),
|
||||||
|
overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT),
|
||||||
|
generateImage: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.'
|
||||||
|
),
|
||||||
|
imageQuery: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(120)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'),
|
||||||
metadata: agentLandingPageMetadataUpdate.optional()
|
metadata: agentLandingPageMetadataUpdate.optional()
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) =>
|
(data) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getDocumentText } from '@api/services/agent/document';
|
|||||||
import { redis } from '@api/utils/redis/redis';
|
import { redis } from '@api/utils/redis/redis';
|
||||||
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CoursePlanSchema } from '@cio/ai-assistant';
|
import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant';
|
||||||
|
|
||||||
type ResourceOwnershipRow = {
|
type ResourceOwnershipRow = {
|
||||||
courseId: string | null;
|
courseId: string | null;
|
||||||
@@ -137,7 +137,7 @@ type PlanMetadataMessage = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanSchema> | undefined {
|
export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof CoursePlanFieldsSchema> | undefined {
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
const message = messages[index] as PlanMetadataMessage;
|
const message = messages[index] as PlanMetadataMessage;
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedPlan = CoursePlanSchema.safeParse(message.metadata.plan.payload);
|
const parsedPlan = CoursePlanFieldsSchema.safeParse(message.metadata.plan.payload);
|
||||||
|
|
||||||
if (parsedPlan.success) {
|
if (parsedPlan.success) {
|
||||||
return parsedPlan.data;
|
return parsedPlan.data;
|
||||||
@@ -158,3 +158,26 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer<typeof
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COURSE_TEMPLATE_ID_SET = new Set<CourseTemplateId>(['product_101', 'product_onboarding', 'expert_on_x']);
|
||||||
|
|
||||||
|
export function getActiveCourseTemplateId(messages: unknown[]): CourseTemplateId | undefined {
|
||||||
|
for (const message of messages) {
|
||||||
|
const candidate = message as {
|
||||||
|
role?: string;
|
||||||
|
metadata?: { template?: { id?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (candidate.role !== 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = candidate.metadata?.template?.id;
|
||||||
|
|
||||||
|
if (id && COURSE_TEMPLATE_ID_SET.has(id as CourseTemplateId)) {
|
||||||
|
return id as CourseTemplateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,23 +2,153 @@ import {
|
|||||||
listChatConversations,
|
listChatConversations,
|
||||||
getChatConversation,
|
getChatConversation,
|
||||||
createChatConversation,
|
createChatConversation,
|
||||||
saveChatMessages,
|
saveChatMessages as saveDbChatMessages,
|
||||||
deleteChatConversation,
|
deleteChatConversation,
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
getChatHistory,
|
getChatHistory,
|
||||||
saveChatHistory,
|
saveChatHistory as saveDbChatHistory,
|
||||||
deleteChatHistory
|
deleteChatHistory
|
||||||
} from '@cio/db/queries/agent';
|
} from '@cio/db/queries/agent';
|
||||||
|
|
||||||
|
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
|
||||||
|
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
|
||||||
|
const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolName(part: Record<string, unknown>): string | null {
|
||||||
|
const type = part.type;
|
||||||
|
|
||||||
|
if (type === 'tool-invocation') {
|
||||||
|
const toolInvocation = part.toolInvocation;
|
||||||
|
|
||||||
|
return isRecord(toolInvocation) && typeof toolInvocation.toolName === 'string' ? toolInvocation.toolName : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof type === 'string' && type.startsWith('tool-')) {
|
||||||
|
return type.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTruncatedOutput(output: unknown): unknown {
|
||||||
|
const serialized = serializeValue(output);
|
||||||
|
|
||||||
|
if (serialized.length <= MAX_PERSISTED_TOOL_OUTPUT_CHARS) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = serialized.slice(0, MAX_PERSISTED_TOOL_OUTPUT_CHARS) + '...[truncated]';
|
||||||
|
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(output)) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preservedScalars = Object.fromEntries(
|
||||||
|
Object.entries(output).filter(
|
||||||
|
([, value]) => value == null || ['string', 'number', 'boolean'].includes(typeof value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preservedScalars,
|
||||||
|
truncated: true,
|
||||||
|
preview
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMessagePart(part: unknown): unknown | null {
|
||||||
|
if (!isRecord(part)) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof part.type === 'string' ? part.type : '';
|
||||||
|
|
||||||
|
if (DROPPED_PART_TYPES.has(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = getToolName(part);
|
||||||
|
|
||||||
|
if (!toolName || TOOLS_WITH_FULL_OUTPUT.has(toolName)) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'tool-invocation' && isRecord(part.toolInvocation)) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
toolInvocation: {
|
||||||
|
...part.toolInvocation,
|
||||||
|
result: buildTruncatedOutput(part.toolInvocation.result)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.state !== 'output-available' || part.output == null) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
output: buildTruncatedOutput(part.output)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeChatMessages(messages: unknown[]): unknown[] {
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (!isRecord(message) || !Array.isArray(message.parts)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedParts = message.parts.map(sanitizeMessagePart).filter((part) => part !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: sanitizedParts
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChatMessages(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
messages: unknown[],
|
||||||
|
title?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await saveDbChatMessages(conversationId, userId, sanitizeChatMessages(messages), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistChatHistory(courseId: string, userId: string, messages: unknown[]): Promise<void> {
|
||||||
|
await saveDbChatHistory(courseId, userId, sanitizeChatMessages(messages));
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
listChatConversations,
|
listChatConversations,
|
||||||
getChatConversation,
|
getChatConversation,
|
||||||
createChatConversation,
|
createChatConversation,
|
||||||
saveChatMessages,
|
|
||||||
deleteChatConversation,
|
deleteChatConversation,
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
// Legacy compat
|
// Legacy compat
|
||||||
getChatHistory as fetchChatHistory,
|
getChatHistory as fetchChatHistory,
|
||||||
saveChatHistory as persistChatHistory,
|
persistChatHistory,
|
||||||
deleteChatHistory as clearChatHistory
|
deleteChatHistory as clearChatHistory
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@api/services/agent/chat-context';
|
} from '@api/services/agent/chat-context';
|
||||||
import {
|
import {
|
||||||
addQuestionsParam,
|
addQuestionsParam,
|
||||||
|
askTemplateQuestionsParam,
|
||||||
coursePlanParam,
|
coursePlanParam,
|
||||||
createExerciseParam,
|
createExerciseParam,
|
||||||
createExerciseSectionParam,
|
createExerciseSectionParam,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
createSectionParam,
|
createSectionParam,
|
||||||
emptyParam,
|
emptyParam,
|
||||||
exerciseReadParam,
|
exerciseReadParam,
|
||||||
|
fetchDocumentationUrlParam,
|
||||||
goLiveParam,
|
goLiveParam,
|
||||||
lessonReadParam,
|
lessonReadParam,
|
||||||
reorderContentParam,
|
reorderContentParam,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
updateQuestionsParam,
|
updateQuestionsParam,
|
||||||
updateSectionParam
|
updateSectionParam
|
||||||
} from '@api/services/agent/agent-tool-schemas';
|
} from '@api/services/agent/agent-tool-schemas';
|
||||||
|
import { fetchDocumentationUrl } from '@api/services/agent/fetch-url';
|
||||||
|
|
||||||
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
function summarizeAgentDebugValue(value: unknown, depth = 0): unknown {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
@@ -184,7 +187,7 @@ async function executeAgentTool<TArgs, TResult>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentTools(orgId: string, userId: string, courseId: string) {
|
export function buildAgentTools(orgId: string, userId: string, courseId: string, priorMessages: unknown[]) {
|
||||||
return {
|
return {
|
||||||
get_course_structure: tool({
|
get_course_structure: tool({
|
||||||
description:
|
description:
|
||||||
@@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
lessonId: args.lessonId,
|
lessonId: args.lessonId,
|
||||||
|
lessonTitle: lesson.title,
|
||||||
locale: args.locale,
|
locale: args.locale,
|
||||||
contentLength: normalizedContent.length,
|
contentLength: normalizedContent.length,
|
||||||
updated: true
|
updated: true
|
||||||
@@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
|||||||
];
|
];
|
||||||
|
|
||||||
await updateExerciseService(args.exerciseId, { questions: allQuestions });
|
await updateExerciseService(args.exerciseId, { questions: allQuestions });
|
||||||
return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length };
|
return {
|
||||||
|
exerciseId: args.exerciseId,
|
||||||
|
exerciseTitle: existingExercise.title,
|
||||||
|
addedCount: args.questions.length,
|
||||||
|
totalCount: allQuestions.length
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -545,7 +554,11 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
|||||||
|
|
||||||
await updateExerciseService(args.exerciseId, { questions: merged });
|
await updateExerciseService(args.exerciseId, { questions: merged });
|
||||||
|
|
||||||
return { exerciseId: args.exerciseId, updatedCount: merged.length };
|
return {
|
||||||
|
exerciseId: args.exerciseId,
|
||||||
|
exerciseTitle: existing.title,
|
||||||
|
updatedCount: merged.length
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -586,7 +599,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
|||||||
|
|
||||||
update_course_landing_page: tool({
|
update_course_landing_page: tool({
|
||||||
description:
|
description:
|
||||||
'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.',
|
'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.',
|
||||||
inputSchema: updateCourseLandingPageParam,
|
inputSchema: updateCourseLandingPageParam,
|
||||||
execute: async (args) => {
|
execute: async (args) => {
|
||||||
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
|
return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => {
|
||||||
@@ -650,6 +663,42 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string)
|
|||||||
return plan;
|
return plan;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
ask_template_questions: tool({
|
||||||
|
description:
|
||||||
|
'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.',
|
||||||
|
inputSchema: askTemplateQuestionsParam,
|
||||||
|
execute: async (args) => {
|
||||||
|
return executeAgentTool(
|
||||||
|
'ask_template_questions',
|
||||||
|
{ orgId, userId, courseId, args },
|
||||||
|
async () =>
|
||||||
|
({
|
||||||
|
awaiting_user: true as const,
|
||||||
|
templateId: args.templateId,
|
||||||
|
title: args.title,
|
||||||
|
description: args.description,
|
||||||
|
fields: args.fields
|
||||||
|
}) as const
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
fetch_documentation_url: tool({
|
||||||
|
description:
|
||||||
|
'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.',
|
||||||
|
inputSchema: fetchDocumentationUrlParam,
|
||||||
|
execute: async (args) => {
|
||||||
|
return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => {
|
||||||
|
return fetchDocumentationUrl({
|
||||||
|
url: args.url,
|
||||||
|
orgId,
|
||||||
|
courseId,
|
||||||
|
priorMessages
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {
|
const TITLE_MODELS: Record<AIProvider, string> = {
|
||||||
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
[AIProvider.OPENAI]: 'gpt-4o-mini',
|
||||||
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
|
[AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001',
|
||||||
[AIProvider.GOOGLE]: 'gemini-2.5-flash',
|
[AIProvider.GOOGLE]: 'gemini-3.1-flash-lite',
|
||||||
[AIProvider.MOONSHOT]: 'kimi-k2.6'
|
[AIProvider.MOONSHOT]: 'kimi-k2.6'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export async function generateConversationTitle(
|
|||||||
|
|
||||||
const truncatedMessage = firstMessageText.slice(0, 1000);
|
const truncatedMessage = firstMessageText.slice(0, 1000);
|
||||||
|
|
||||||
// Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
|
// Gemini 3.x counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text.
|
||||||
const { text } = await generateText(
|
const { text } = await generateText(
|
||||||
providerConfig.provider === AIProvider.GOOGLE
|
providerConfig.provider === AIProvider.GOOGLE
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { defaultAiTutorSettings, mergeAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||||
|
import {
|
||||||
|
getCourseAiTutorOverride,
|
||||||
|
getOrgAiTutorSettings,
|
||||||
|
updateCourseAiTutorOverride,
|
||||||
|
updateOrgAiTutorSettings
|
||||||
|
} from '@cio/db/queries/agent';
|
||||||
|
|
||||||
|
import { AppError } from '@api/utils/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved AI tutor settings for a given (org, course) pair.
|
||||||
|
*
|
||||||
|
* Merge order (later overrides earlier):
|
||||||
|
* defaultAiTutorSettings → org.aiTutorSettings → course.metadata.aiTutor
|
||||||
|
*
|
||||||
|
* If the course override has `inheritFromOrg !== false`, the org defaults apply
|
||||||
|
* for every field the course did not explicitly set. The course can still flip
|
||||||
|
* individual fields (including `enabled`).
|
||||||
|
*/
|
||||||
|
export async function getEffectiveAiTutorSettings(orgId: string, courseId: string): Promise<AiTutorSettings> {
|
||||||
|
const [orgSettings, courseOverride] = await Promise.all([
|
||||||
|
getOrgAiTutorSettings(orgId),
|
||||||
|
getCourseAiTutorOverride(courseId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const orgResolved = mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
|
||||||
|
|
||||||
|
if (!courseOverride) return orgResolved;
|
||||||
|
if (courseOverride.inheritFromOrg === true) return orgResolved;
|
||||||
|
|
||||||
|
const { inheritFromOrg: _drop, ...override } = courseOverride;
|
||||||
|
return mergeAiTutorSettings(orgResolved, override as Partial<AiTutorSettings>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResolvedOrgAiTutorSettings(orgId: string): Promise<AiTutorSettings> {
|
||||||
|
const orgSettings = await getOrgAiTutorSettings(orgId);
|
||||||
|
return mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial<AiTutorSettings> | null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrgAiTutorSettingsService(
|
||||||
|
orgId: string,
|
||||||
|
patch: Partial<AiTutorSettings>
|
||||||
|
): Promise<AiTutorSettings> {
|
||||||
|
const updated = await updateOrgAiTutorSettings(orgId, patch);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new AppError('Organization not found', 'ORGANIZATION_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeAiTutorSettings(defaultAiTutorSettings, updated as Partial<AiTutorSettings>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseAiTutorSettingsView {
|
||||||
|
org: AiTutorSettings;
|
||||||
|
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null;
|
||||||
|
effective: AiTutorSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseAiTutorSettingsView(
|
||||||
|
orgId: string,
|
||||||
|
courseId: string
|
||||||
|
): Promise<CourseAiTutorSettingsView> {
|
||||||
|
const [org, override, effective] = await Promise.all([
|
||||||
|
getResolvedOrgAiTutorSettings(orgId),
|
||||||
|
getCourseAiTutorOverride(courseId),
|
||||||
|
getEffectiveAiTutorSettings(orgId, courseId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
org,
|
||||||
|
override: (override as (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null) ?? null,
|
||||||
|
effective
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCourseAiTutorOverrideService(
|
||||||
|
courseId: string,
|
||||||
|
patch: Partial<AiTutorSettings> & { inheritFromOrg?: boolean }
|
||||||
|
): Promise<Partial<AiTutorSettings> & { inheritFromOrg?: boolean }> {
|
||||||
|
const updated = await updateCourseAiTutorOverride(courseId, patch);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated as Partial<AiTutorSettings> & { inheritFromOrg?: boolean };
|
||||||
|
}
|
||||||
@@ -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 ───────────────────────────────────────────────────
|
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
|
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
|
||||||
|
// gemini-2.5-flash kept for backward compat with historical usage records.
|
||||||
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
||||||
'gemini-2.5-flash': 1,
|
'gemini-3.1-flash-lite': 1,
|
||||||
'gpt-5.4-mini': 3,
|
'gemini-2.5-flash': 1.5,
|
||||||
|
'gpt-5.4-mini': 4,
|
||||||
'claude-sonnet-4-6': 11,
|
'claude-sonnet-4-6': 11,
|
||||||
'claude-haiku-4-5-20251001': 1,
|
'claude-haiku-4-5-20251001': 1.5,
|
||||||
'kimi-k2.6': 3
|
'kimi-k2.6': 4
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
|
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
|
||||||
|
|||||||
@@ -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,
|
TAssetUpdate,
|
||||||
TYouTubeMetadataQuery
|
TYouTubeMetadataQuery
|
||||||
} from '@cio/utils/validation/assets';
|
} from '@cio/utils/validation/assets';
|
||||||
|
import type { TTranscriptResponse } from '@cio/utils/validation/media';
|
||||||
import {
|
import {
|
||||||
countAssetUsagesByAsset,
|
countAssetUsagesByAsset,
|
||||||
createAssetUsage,
|
createAssetUsage,
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
listAssetUsagesByAsset,
|
listAssetUsagesByAsset,
|
||||||
updateAsset
|
updateAsset
|
||||||
} from '@cio/db/queries/assets';
|
} from '@cio/db/queries/assets';
|
||||||
|
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||||
|
|
||||||
|
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
|
||||||
|
|
||||||
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ export async function listOrganizationAssetsService(orgId: string, query: TAsset
|
|||||||
|
|
||||||
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
|
export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) {
|
||||||
try {
|
try {
|
||||||
return await createOrGetAssetByStorageKey({
|
const asset = await createOrGetAssetByStorageKey({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
kind: data.kind,
|
kind: data.kind,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
@@ -230,6 +234,19 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
|
|||||||
metadata: data.metadata ?? {},
|
metadata: data.metadata ?? {},
|
||||||
createdByProfileId: profileId
|
createdByProfileId: profileId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget: enqueue lesson-video post-processing for new uploads.
|
||||||
|
// Errors are logged but never block the asset create response.
|
||||||
|
if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) {
|
||||||
|
void enqueueMediaPostProcessingForAsset({
|
||||||
|
organizationId: orgId,
|
||||||
|
assetId: asset.id,
|
||||||
|
storageKey: asset.storageKey,
|
||||||
|
triggeredByProfileId: profileId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
error instanceof Error ? error.message : 'Failed to create asset',
|
error instanceof Error ? error.message : 'Failed to create asset',
|
||||||
@@ -239,6 +256,26 @@ export async function createAssetFromUploadService(orgId: string, profileId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enqueueMediaPostProcessingForAsset(input: {
|
||||||
|
organizationId: string;
|
||||||
|
assetId: string;
|
||||||
|
storageKey: string;
|
||||||
|
triggeredByProfileId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startMediaJob } = await import('@api/services/jobs');
|
||||||
|
await startMediaJob({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
assetId: input.assetId,
|
||||||
|
storageKey: input.storageKey,
|
||||||
|
triggeredByProfileId: input.triggeredByProfileId,
|
||||||
|
withTranscription: Boolean(process.env.OPENAI_API_KEY)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('enqueueMediaPostProcessingForAsset failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAssetService(orgId: string, assetId: string) {
|
export async function getAssetService(orgId: string, assetId: string) {
|
||||||
try {
|
try {
|
||||||
const asset = await getAssetById(assetId, orgId);
|
const asset = await getAssetById(assetId, orgId);
|
||||||
@@ -394,6 +431,42 @@ export async function getOrganizationAssetStorageService(orgId: string, query: T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTranscriptForOrganizationAssetService(
|
||||||
|
orgId: string,
|
||||||
|
assetId: string
|
||||||
|
): Promise<TTranscriptResponse | null> {
|
||||||
|
try {
|
||||||
|
const asset = assertAssetExists(await getAssetById(assetId));
|
||||||
|
|
||||||
|
if (asset.organizationId !== orgId) {
|
||||||
|
throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await getMediaTranscriptByAsset(assetId, orgId);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket);
|
||||||
|
const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
language: row.language,
|
||||||
|
segments: row.segments,
|
||||||
|
vttUrl,
|
||||||
|
vttUrlExpiresAt,
|
||||||
|
durationSeconds: row.durationSeconds ?? null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
|
throw new AppError(
|
||||||
|
error instanceof Error ? error.message : 'Failed to fetch transcript',
|
||||||
|
ErrorCodes.ASSET_FETCH_FAILED,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
|
export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
|
||||||
const videoId = extractYouTubeVideoId(query.url);
|
const videoId = extractYouTubeVideoId(query.url);
|
||||||
if (!videoId) {
|
if (!videoId) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
|
import { getCourseById, getOrganizationById, getProfileById, getCourseOrganizationId } from '@cio/db/queries';
|
||||||
|
import { resolveCertificateDesign, type CertificateRenderInput } from '@api/utils/certificate';
|
||||||
|
import type { TCertificateDownloadRequest } from '@cio/utils/validation/course';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the design + render data for a given course/student pair so the API
|
||||||
|
* can hand it to `generateCertificatePdf` / `generateCertificatePng`.
|
||||||
|
*
|
||||||
|
* The client only sends `studentName` (+ optional studentId/issuedAt). Everything
|
||||||
|
* else comes from the database: title, description, org name + logo, design.
|
||||||
|
*/
|
||||||
|
export async function assembleCertificateRender(
|
||||||
|
courseId: string,
|
||||||
|
body: TCertificateDownloadRequest
|
||||||
|
): Promise<CertificateRenderInput> {
|
||||||
|
const [courseRow] = await getCourseById(courseId);
|
||||||
|
if (!courseRow) {
|
||||||
|
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = await getCourseOrganizationId(courseId);
|
||||||
|
const organization = organizationId ? await getOrganizationById(organizationId) : null;
|
||||||
|
|
||||||
|
const design = resolveCertificateDesign(courseRow.certificate);
|
||||||
|
|
||||||
|
const issuedAtIso = body.issuedAt ?? new Date().toISOString();
|
||||||
|
const issuedAtDate = new Date(issuedAtIso);
|
||||||
|
const date = Number.isNaN(issuedAtDate.getTime())
|
||||||
|
? new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' })
|
||||||
|
: issuedAtDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' });
|
||||||
|
|
||||||
|
const certificateId = body.studentId
|
||||||
|
? formatCertificateId(design.idFormat, body.studentId, issuedAtDate)
|
||||||
|
: formatCertificateId(design.idFormat, fallbackSequence(issuedAtDate), issuedAtDate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
design,
|
||||||
|
data: {
|
||||||
|
recipientName: body.studentName,
|
||||||
|
courseName: courseRow.title,
|
||||||
|
courseDescription: courseRow.description ?? '',
|
||||||
|
orgName: organization?.name ?? '',
|
||||||
|
orgLogoUrl: organization?.avatarUrl ?? undefined,
|
||||||
|
date,
|
||||||
|
certificateId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCertificateId(format: string | undefined, seq: string, issuedAt: Date): string {
|
||||||
|
const year = issuedAt.getFullYear();
|
||||||
|
const month = String(issuedAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const tail = seq.replace(/-/g, '').slice(-4).toUpperCase() || '0001';
|
||||||
|
|
||||||
|
return (format ?? 'N° {seq}').replace('{seq}', tail).replace('{year}', String(year)).replace('{month}', month);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSequence(issuedAt: Date): string {
|
||||||
|
return String(issuedAt.getTime()).slice(-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assembleOwnerPreviewRender(
|
||||||
|
courseId: string,
|
||||||
|
userId: string,
|
||||||
|
body: TCertificateDownloadRequest
|
||||||
|
): Promise<CertificateRenderInput> {
|
||||||
|
const studentName = body.studentName.trim().length
|
||||||
|
? body.studentName
|
||||||
|
: (await getProfileById(userId))?.fullname || 'Preview Recipient';
|
||||||
|
|
||||||
|
return assembleCertificateRender(courseId, { ...body, studentName });
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type TCourseCertificationRow
|
type TCourseCertificationRow
|
||||||
} from '@cio/db/queries/course/course';
|
} from '@cio/db/queries/course/course';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
|
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||||
|
|
||||||
export type CertificationBlocker = {
|
export type CertificationBlocker = {
|
||||||
code:
|
code:
|
||||||
@@ -240,6 +241,22 @@ export async function evaluateCourseCertification(
|
|||||||
courseRow,
|
courseRow,
|
||||||
earnedAt
|
earnedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trackServerEvent({
|
||||||
|
eventType: SERVER_EVENTS.COURSE_COMPLETED,
|
||||||
|
userId: profileId,
|
||||||
|
courseId,
|
||||||
|
props: { path: 'standard', earnedAt }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (courseRow.certificate?.isDownloadable) {
|
||||||
|
trackServerEvent({
|
||||||
|
eventType: SERVER_EVENTS.CERTIFICATE_ISSUED,
|
||||||
|
userId: profileId,
|
||||||
|
courseId,
|
||||||
|
props: { path: 'standard', earnedAt }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...evaluation, certificateEarnedAt, isNewCompletion };
|
return { ...evaluation, certificateEarnedAt, isNewCompletion };
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
getCourseComplianceHistoryRows,
|
getCourseComplianceHistoryRows,
|
||||||
getCourseCurrentComplianceRows,
|
getCourseCurrentComplianceRows,
|
||||||
getLatestComplianceRecordsByProfiles,
|
getLatestComplianceRecordsByProfiles,
|
||||||
|
getOrgComplianceLearnerRows,
|
||||||
getStudentCourseMembersForCompliance,
|
getStudentCourseMembersForCompliance,
|
||||||
listComplianceRecordsReadyForExpiry,
|
listComplianceRecordsReadyForExpiry,
|
||||||
listLatestComplianceRecordsForReminderScan,
|
listLatestComplianceRecordsForReminderScan,
|
||||||
updateCourseCertificateIssueStatusByRecordId,
|
updateCourseCertificateIssueStatusByRecordId,
|
||||||
updateCourseCompletionRecord
|
updateCourseCompletionRecord,
|
||||||
|
type OrgComplianceLearnerRow
|
||||||
} from '@cio/db/queries/course/compliance';
|
} from '@cio/db/queries/course/compliance';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
import { getCourseById } from '@cio/db/queries/course';
|
import { getCourseById } from '@cio/db/queries/course';
|
||||||
@@ -712,3 +714,108 @@ export async function runComplianceReminderScan() {
|
|||||||
reminderEventCount
|
reminderEventCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrgComplianceStatus = ComplianceStatus | 'no_record';
|
||||||
|
|
||||||
|
const STATUS_KEYS: OrgComplianceStatus[] = [
|
||||||
|
'compliant',
|
||||||
|
'expiring_soon',
|
||||||
|
'in_grace_period',
|
||||||
|
'non_compliant',
|
||||||
|
'waived',
|
||||||
|
'in_progress',
|
||||||
|
'not_started',
|
||||||
|
'no_record'
|
||||||
|
];
|
||||||
|
|
||||||
|
function emptyStatusCounts() {
|
||||||
|
const counts = {} as Record<OrgComplianceStatus, number>;
|
||||||
|
for (const status of STATUS_KEYS) counts[status] = 0;
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgComplianceOverview = {
|
||||||
|
summary: {
|
||||||
|
totalLearners: number;
|
||||||
|
totalCourses: number;
|
||||||
|
counts: Record<OrgComplianceStatus, number>;
|
||||||
|
};
|
||||||
|
courses: Array<{
|
||||||
|
courseId: string;
|
||||||
|
courseTitle: string;
|
||||||
|
learnerCount: number;
|
||||||
|
counts: Record<OrgComplianceStatus, number>;
|
||||||
|
}>;
|
||||||
|
learners: Array<{
|
||||||
|
groupMemberId: string;
|
||||||
|
profileId: string | null;
|
||||||
|
fullname: string | null;
|
||||||
|
email: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
courseId: string;
|
||||||
|
courseTitle: string;
|
||||||
|
status: OrgComplianceStatus;
|
||||||
|
cycleNumber: number | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
validUntil: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates compliance state across every COMPLIANCE course in an
|
||||||
|
* organization. Returns status tallies, per-course breakdown, and the
|
||||||
|
* flat learner-by-course list for the dashboard table.
|
||||||
|
*/
|
||||||
|
export async function getOrgComplianceOverview(orgId: string): Promise<OrgComplianceOverview> {
|
||||||
|
const rows = await getOrgComplianceLearnerRows(orgId);
|
||||||
|
|
||||||
|
const summaryCounts = emptyStatusCounts();
|
||||||
|
const courseMap = new Map<
|
||||||
|
string,
|
||||||
|
{ title: string; learnerCount: number; counts: Record<OrgComplianceStatus, number> }
|
||||||
|
>();
|
||||||
|
const uniqueLearners = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const status = row.status as OrgComplianceStatus;
|
||||||
|
summaryCounts[status] = (summaryCounts[status] ?? 0) + 1;
|
||||||
|
if (row.profileId) uniqueLearners.add(row.profileId);
|
||||||
|
|
||||||
|
let courseAcc = courseMap.get(row.courseId);
|
||||||
|
if (!courseAcc) {
|
||||||
|
courseAcc = { title: row.courseTitle, learnerCount: 0, counts: emptyStatusCounts() };
|
||||||
|
courseMap.set(row.courseId, courseAcc);
|
||||||
|
}
|
||||||
|
courseAcc.learnerCount += 1;
|
||||||
|
courseAcc.counts[status] = (courseAcc.counts[status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalLearners: uniqueLearners.size,
|
||||||
|
totalCourses: courseMap.size,
|
||||||
|
counts: summaryCounts
|
||||||
|
},
|
||||||
|
courses: Array.from(courseMap.entries()).map(([courseId, c]) => ({
|
||||||
|
courseId,
|
||||||
|
courseTitle: c.title,
|
||||||
|
learnerCount: c.learnerCount,
|
||||||
|
counts: c.counts
|
||||||
|
})),
|
||||||
|
learners: rows.map((row: OrgComplianceLearnerRow) => ({
|
||||||
|
groupMemberId: row.groupMemberId,
|
||||||
|
profileId: row.profileId,
|
||||||
|
fullname: row.profile?.fullname ?? null,
|
||||||
|
email: row.email,
|
||||||
|
avatarUrl: row.profile?.avatarUrl ?? null,
|
||||||
|
courseId: row.courseId,
|
||||||
|
courseTitle: row.courseTitle,
|
||||||
|
status: row.status as OrgComplianceStatus,
|
||||||
|
cycleNumber: row.cycleNumber,
|
||||||
|
dueDate: row.dueDate,
|
||||||
|
completedAt: row.completedAt,
|
||||||
|
validUntil: row.validUntil
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ import crypto from 'node:crypto';
|
|||||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
|
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||||
|
|
||||||
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
|
||||||
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
|
||||||
@@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: {
|
|||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
|
const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
|
||||||
await sendEmail('studentCourseWelcome', {
|
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||||
to: input.studentEmail,
|
to: input.studentEmail,
|
||||||
fields: {
|
fields: {
|
||||||
orgName: input.orgName,
|
orgName: input.orgName,
|
||||||
courseName: input.courseName,
|
courseName: input.courseName,
|
||||||
loginUrl
|
loginUrl
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send student welcome email:', error);
|
console.error('Failed to enqueue student welcome email:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: {
|
|||||||
const studentProfile = await getProfileById(input.studentId);
|
const studentProfile = await getProfileById(input.studentId);
|
||||||
const studentName = studentProfile?.fullname || input.studentEmail;
|
const studentName = studentProfile?.fullname || input.studentEmail;
|
||||||
|
|
||||||
await Promise.all(
|
const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email);
|
||||||
teachers
|
|
||||||
.map((teacher) => teacher.email)
|
if (teacherEmails.length === 0) return;
|
||||||
.filter((email): email is string => !!email)
|
|
||||||
.map((teacherEmail) =>
|
await enqueueTransactionalEmail('teacherStudentJoined', {
|
||||||
sendEmail('teacherStudentJoined', {
|
to: teacherEmails,
|
||||||
to: teacherEmail,
|
|
||||||
fields: {
|
fields: {
|
||||||
courseName: input.courseName,
|
courseName: input.courseName,
|
||||||
studentName,
|
studentName,
|
||||||
studentEmail: input.studentEmail
|
studentEmail: input.studentEmail
|
||||||
},
|
},
|
||||||
from: buildEmailFromName('ClassroomIO')
|
from: buildEmailFromName('ClassroomIO'),
|
||||||
}).catch((error) => {
|
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
|
||||||
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error);
|
});
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send teacher join notification emails:', error);
|
console.error('Failed to enqueue teacher join notification emails:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +433,7 @@ async function createEmailInviteAndSend(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responses = await sendEmail('studentCourseInvite', {
|
await enqueueTransactionalEmail('studentCourseInvite', {
|
||||||
to: input.recipientEmail,
|
to: input.recipientEmail,
|
||||||
fields: {
|
fields: {
|
||||||
orgName: input.orgName,
|
orgName: input.orgName,
|
||||||
@@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: {
|
|||||||
inviteLink: createdInvite.inviteLink,
|
inviteLink: createdInvite.inviteLink,
|
||||||
expiresAt: getExpiryLabel(createdInvite.expiresAt)
|
expiresAt: getExpiryLabel(createdInvite.expiresAt)
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `course-invite-email:${createdInvite.id}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const allSuccessful = responses.every((response) => response.success);
|
// Optimistic EMAIL_SENT — see comment in services/organization/invite.ts.
|
||||||
if (!allSuccessful) {
|
|
||||||
const errorMessage = responses
|
|
||||||
.filter((response) => !response.success)
|
|
||||||
.map((response) => response.error || 'Unknown email error')
|
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_FAILED', {
|
|
||||||
actorProfileId: input.createdByProfileId,
|
|
||||||
targetEmail: input.recipientEmail,
|
|
||||||
metadata: { error: errorMessage }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
invite: createdInvite,
|
|
||||||
sent: false,
|
|
||||||
error: errorMessage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
|
await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', {
|
||||||
actorProfileId: input.createdByProfileId,
|
actorProfileId: input.createdByProfileId,
|
||||||
targetEmail: input.recipientEmail
|
targetEmail: input.recipientEmail
|
||||||
@@ -683,6 +664,14 @@ export async function enrollInCourse(
|
|||||||
studentEmail: normalizedEmail
|
studentEmail: normalizedEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trackServerEvent({
|
||||||
|
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
|
||||||
|
orgId: org.id,
|
||||||
|
userId: user.id,
|
||||||
|
courseId,
|
||||||
|
props: { path: 'free-enrollment' }
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alreadyJoined: false,
|
alreadyJoined: false,
|
||||||
@@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
studentId: user.id,
|
studentId: user.id,
|
||||||
studentEmail: normalizedEmail
|
studentEmail: normalizedEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trackServerEvent({
|
||||||
|
eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED,
|
||||||
|
userId: user.id,
|
||||||
|
courseId: result.courseId,
|
||||||
|
props: { path: 'invite' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
|
|||||||
|
|
||||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
|
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||||
|
|
||||||
export interface PaymentRequestData {
|
export interface PaymentRequestData {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
@@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
|||||||
|
|
||||||
const teacherEmail = teacherResult[0].email;
|
const teacherEmail = teacherResult[0].email;
|
||||||
|
|
||||||
// Send email to teacher about student buy request
|
|
||||||
try {
|
try {
|
||||||
await sendEmail('teacherStudentBuyRequest', {
|
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
|
||||||
to: teacherEmail,
|
to: teacherEmail,
|
||||||
fields: {
|
fields: {
|
||||||
courseName,
|
courseName,
|
||||||
studentEmail: data.studentEmail,
|
studentEmail: data.studentEmail,
|
||||||
studentFullname: data.studentFullname
|
studentFullname: data.studentFullname
|
||||||
},
|
},
|
||||||
from: buildEmailFromName('ClassroomIO')
|
from: buildEmailFromName('ClassroomIO'),
|
||||||
|
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
|
||||||
});
|
});
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to send teacher buy request email:', emailError);
|
console.error('Failed to enqueue teacher buy request email:', emailError);
|
||||||
// Don't fail the request if email fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email to student with payment instructions
|
|
||||||
try {
|
try {
|
||||||
await sendEmail('studentProvePayment', {
|
await enqueueTransactionalEmail('studentProvePayment', {
|
||||||
to: data.studentEmail,
|
to: data.studentEmail,
|
||||||
fields: {
|
fields: {
|
||||||
courseName,
|
courseName,
|
||||||
@@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
|||||||
orgName
|
orgName
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||||
replyTo: teacherEmail
|
replyTo: teacherEmail,
|
||||||
|
idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}`
|
||||||
});
|
});
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to send student payment proof email:', emailError);
|
console.error('Failed to enqueue student payment proof email:', emailError);
|
||||||
// Don't fail the request if email fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackServerEvent({
|
||||||
|
eventType: SERVER_EVENTS.ENROLLMENT_STARTED,
|
||||||
|
courseId: data.courseId,
|
||||||
|
props: { path: 'payment-request', orgName }
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
courseName,
|
courseName,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types';
|
|||||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,45 +86,44 @@ export async function addMember(
|
|||||||
studentName = data.name || null;
|
studentName = data.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send welcome email to student
|
|
||||||
if (studentEmail) {
|
if (studentEmail) {
|
||||||
try {
|
try {
|
||||||
await sendEmail('studentCourseWelcome', {
|
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||||
to: studentEmail,
|
to: studentEmail,
|
||||||
fields: {
|
fields: {
|
||||||
orgName,
|
orgName,
|
||||||
courseName,
|
courseName,
|
||||||
loginUrl
|
loginUrl
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`
|
||||||
});
|
});
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Failed to send student welcome email:', emailError);
|
console.error('Failed to enqueue student welcome email:', emailError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all teachers (ADMIN or TUTOR) in the course
|
|
||||||
const teachersResult = await getCourseTeachers({ courseId });
|
const teachersResult = await getCourseTeachers({ courseId });
|
||||||
|
|
||||||
// Send notification to all teachers
|
|
||||||
if (teachersResult.length > 0 && studentEmail && studentName) {
|
if (teachersResult.length > 0 && studentEmail && studentName) {
|
||||||
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
|
const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null);
|
||||||
|
|
||||||
await Promise.all(
|
if (teacherEmails.length > 0) {
|
||||||
teacherEmails.map((teacherEmail) =>
|
try {
|
||||||
sendEmail('teacherStudentJoined', {
|
await enqueueTransactionalEmail('teacherStudentJoined', {
|
||||||
to: teacherEmail,
|
to: teacherEmails,
|
||||||
fields: {
|
fields: {
|
||||||
courseName,
|
courseName,
|
||||||
studentName,
|
studentName,
|
||||||
studentEmail
|
studentEmail
|
||||||
},
|
},
|
||||||
from: buildEmailFromName('ClassroomIO')
|
from: buildEmailFromName('ClassroomIO'),
|
||||||
}).catch((emailError) => {
|
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
|
||||||
console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError);
|
});
|
||||||
})
|
} catch (emailError) {
|
||||||
)
|
console.error('Failed to enqueue teacher notification email:', emailError);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
|||||||
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
const baseUrl = getDashboardBaseUrl(orgSiteName);
|
||||||
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
|
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
|
||||||
|
|
||||||
// Send welcome emails to teachers
|
|
||||||
const teacherEmailPromises: Promise<unknown>[] = [];
|
const teacherEmailPromises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
addedMembers.forEach((member, index) => {
|
addedMembers.forEach((member, index) => {
|
||||||
const memberData = members[index];
|
const memberData = members[index];
|
||||||
// Only send emails to teachers (TUTOR or ADMIN role) who have email
|
|
||||||
const roleId = memberData.roleId;
|
const roleId = memberData.roleId;
|
||||||
const email = memberData.email;
|
const email = memberData.email;
|
||||||
const name = memberData.name;
|
const name = memberData.name;
|
||||||
|
|
||||||
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
|
if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) {
|
||||||
teacherEmailPromises.push(
|
teacherEmailPromises.push(
|
||||||
sendEmail('teacherCourseWelcome', {
|
enqueueTransactionalEmail('teacherCourseWelcome', {
|
||||||
to: email,
|
to: email,
|
||||||
fields: {
|
fields: {
|
||||||
name,
|
name,
|
||||||
@@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
|||||||
courseName,
|
courseName,
|
||||||
inviteLink
|
inviteLink
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
|
||||||
}).catch((emailError) => {
|
}).catch((emailError) => {
|
||||||
console.error(`Failed to send welcome email to ${email}:`, emailError);
|
console.error(`Failed to enqueue welcome email to ${email}:`, emailError);
|
||||||
return [];
|
return [];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
|||||||
try {
|
try {
|
||||||
await Promise.all(teacherEmailPromises);
|
await Promise.all(teacherEmailPromises);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
// Log but don't fail the request if email fails
|
console.error('Failed to enqueue welcome emails:', emailError);
|
||||||
console.error('Failed to send welcome emails:', emailError);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
|
import {
|
||||||
|
generateTranscriptVttPresignedUrl,
|
||||||
|
generateVideoDownloadPresignedUrls,
|
||||||
|
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||||
|
} from '@api/utils/s3';
|
||||||
|
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
||||||
|
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||||
|
import { getAssetsByIds } from '@cio/db/queries/assets';
|
||||||
import {
|
import {
|
||||||
getCourseRowBySlug,
|
getCourseRowBySlug,
|
||||||
getPublicCourseItem as getPublicCourseItemQuery,
|
getPublicCourseItem as getPublicCourseItemQuery,
|
||||||
@@ -8,7 +16,6 @@ import {
|
|||||||
} from '@cio/db/queries/course';
|
} from '@cio/db/queries/course';
|
||||||
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||||
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
|
import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section';
|
||||||
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anonymous-safe: fetch the full tree for a published public course by slug.
|
* Anonymous-safe: fetch the full tree for a published public course by slug.
|
||||||
@@ -24,7 +31,7 @@ export async function getPublicCourseTreeService(courseSlug: string): Promise<Pu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PublicItemResponse =
|
export type PublicItemResponse =
|
||||||
| (Extract<PublicCourseItemContent, { kind: 'lesson' }> & { kind: 'lesson' })
|
| (Omit<Extract<PublicCourseItemContent, { kind: 'lesson' }>, 'courseOrganizationId'> & { kind: 'lesson' })
|
||||||
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
|
| (Extract<PublicCourseItemContent, { kind: 'exercise' }> & {
|
||||||
kind: 'exercise';
|
kind: 'exercise';
|
||||||
questions: PublicExercisePayload;
|
questions: PublicExercisePayload;
|
||||||
@@ -74,7 +81,69 @@ export async function getPublicCourseItemService(
|
|||||||
if (!baseItem) return null;
|
if (!baseItem) return null;
|
||||||
|
|
||||||
if (baseItem.kind === 'lesson') {
|
if (baseItem.kind === 'lesson') {
|
||||||
return baseItem;
|
const { courseOrganizationId, ...lesson } = baseItem;
|
||||||
|
|
||||||
|
let video = lesson.video;
|
||||||
|
|
||||||
|
if (video?.type === 'upload') {
|
||||||
|
let storageKey: string | undefined;
|
||||||
|
|
||||||
|
if (video.assetId) {
|
||||||
|
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
|
||||||
|
storageKey = assets[0]?.storageKey ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storageKey && video.key) {
|
||||||
|
storageKey = video.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageKey) {
|
||||||
|
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
|
||||||
|
const signedLink = urls[storageKey];
|
||||||
|
|
||||||
|
if (signedLink) {
|
||||||
|
const { key: _omitKey, ...rest } = video;
|
||||||
|
video = { ...rest, link: signedLink };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video?.assetId && courseOrganizationId) {
|
||||||
|
const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId);
|
||||||
|
let transcript: {
|
||||||
|
vttUrl: string;
|
||||||
|
vttUrlExpiresAt: string;
|
||||||
|
language: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (transcriptRow) {
|
||||||
|
const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket);
|
||||||
|
transcript = {
|
||||||
|
vttUrl,
|
||||||
|
vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(),
|
||||||
|
language: transcriptRow.language
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
video = { ...video, transcript };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video?.key !== undefined) {
|
||||||
|
const { key: _omitS3Key, ...withoutKey } = video;
|
||||||
|
video = withoutKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...lesson, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseItem.isUnlocked) {
|
||||||
|
return {
|
||||||
|
...baseItem,
|
||||||
|
questions: {
|
||||||
|
unsectioned: [],
|
||||||
|
sections: []
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
|
const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id);
|
||||||
|
|||||||
@@ -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';
|
} from '@cio/db/queries/newsfeed';
|
||||||
|
|
||||||
import { env } from '@api/config/env';
|
import { env } from '@api/config/env';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists newsfeed items for a course
|
* Lists newsfeed items for a course
|
||||||
@@ -359,12 +360,14 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
|||||||
const orgSiteName = feedData.organization?.siteName || 'app';
|
const orgSiteName = feedData.organization?.siteName || 'app';
|
||||||
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||||
|
|
||||||
// Send email to all course members
|
const recipients = feedData.courseMembers
|
||||||
const emailPromises = feedData.courseMembers.map((member) => {
|
.map((member) => member.email)
|
||||||
if (!member.email) return Promise.resolve();
|
.filter((email): email is string => Boolean(email));
|
||||||
|
|
||||||
return sendEmail('newsfeedPost', {
|
if (recipients.length === 0) return;
|
||||||
to: member.email,
|
|
||||||
|
await enqueueTransactionalEmail('newsfeedPost', {
|
||||||
|
to: recipients,
|
||||||
fields: {
|
fields: {
|
||||||
courseTitle: feedData.courseTitle!,
|
courseTitle: feedData.courseTitle!,
|
||||||
teacherName: feedData.author?.fullname || 'A teacher',
|
teacherName: feedData.author?.fullname || 'A teacher',
|
||||||
@@ -373,11 +376,9 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
|||||||
orgName
|
orgName
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||||
replyTo: feedData.author?.email || 'noreply@classroomio.com'
|
replyTo: feedData.author?.email || 'noreply@classroomio.com',
|
||||||
|
idempotencyKey: `newsfeed:post:${feedId}`
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(emailPromises);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending newsfeed post email:', error);
|
console.error('Error sending newsfeed post email:', error);
|
||||||
// Don't throw - email failures shouldn't break the API
|
// Don't throw - email failures shouldn't break the API
|
||||||
@@ -403,7 +404,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
|
|||||||
const orgSiteName = feedData.organization?.siteName || 'app';
|
const orgSiteName = feedData.organization?.siteName || 'app';
|
||||||
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
|
||||||
|
|
||||||
await sendEmail('newsfeedComment', {
|
await enqueueTransactionalEmail('newsfeedComment', {
|
||||||
to: feedData.author.email,
|
to: feedData.author.email,
|
||||||
fields: {
|
fields: {
|
||||||
courseTitle: feedData.courseTitle,
|
courseTitle: feedData.courseTitle,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { env } from '@api/config/env';
|
|||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { PLAN } from '@cio/utils/plans';
|
import { PLAN } from '@cio/utils/plans';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
import { sendEmail } from '@cio/email';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
|
|
||||||
export async function createOrganizationWithOwner(
|
export async function createOrganizationWithOwner(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -125,11 +125,12 @@ export async function completeOnboarding(user: Partial<TUser> & { id: string; em
|
|||||||
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendEmail('welcome', {
|
await enqueueTransactionalEmail('welcome', {
|
||||||
to: user.email,
|
to: user.email,
|
||||||
fields: {
|
fields: {
|
||||||
name: profile.fullname
|
name: profile.fullname
|
||||||
}
|
},
|
||||||
|
idempotencyKey: `onboarding:welcome:${user.id}`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
getPublishedCoursesBySiteName
|
getPublishedCoursesBySiteName
|
||||||
} from '@cio/db/queries/course';
|
} from '@cio/db/queries/course';
|
||||||
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag';
|
||||||
|
import { getAccountPrimary } from '@cio/db/queries/account';
|
||||||
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
|
import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics';
|
||||||
|
|
||||||
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
|
import type { OrganizationWithPlans } from '@cio/db/queries/organization/types';
|
||||||
@@ -524,8 +525,13 @@ export async function getOrgSetupData(siteName: string) {
|
|||||||
*/
|
*/
|
||||||
export async function createOrgPlan(data: TNewOrganizationPlan) {
|
export async function createOrgPlan(data: TNewOrganizationPlan) {
|
||||||
try {
|
try {
|
||||||
|
// Subscriptions always attach to the primary workspace, even if a
|
||||||
|
// secondary org id was supplied at checkout.
|
||||||
|
const primary = data.orgId ? await getAccountPrimary(data.orgId) : null;
|
||||||
|
const targetOrgId = primary?.id ?? data.orgId;
|
||||||
|
|
||||||
const plan = await createOrganizationPlan({
|
const plan = await createOrganizationPlan({
|
||||||
orgId: data.orgId,
|
orgId: targetOrgId,
|
||||||
planName: data.planName,
|
planName: data.planName,
|
||||||
subscriptionId: data.subscriptionId,
|
subscriptionId: data.subscriptionId,
|
||||||
triggeredBy: data.triggeredBy,
|
triggeredBy: data.triggeredBy,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type {
|
|||||||
TImportAudienceMembers
|
TImportAudienceMembers
|
||||||
} from '@cio/utils/validation/organization';
|
} from '@cio/utils/validation/organization';
|
||||||
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
||||||
import {
|
import {
|
||||||
createOrganizationInvite,
|
createOrganizationInvite,
|
||||||
@@ -202,18 +203,19 @@ async function enrollAudienceStudentProfilesInCourses(
|
|||||||
.map(async (p) => {
|
.map(async (p) => {
|
||||||
const email = profileEmailMap.get(p.profileId)!;
|
const email = profileEmailMap.get(p.profileId)!;
|
||||||
try {
|
try {
|
||||||
await sendEmail('studentCourseWelcome', {
|
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||||
to: email,
|
to: email,
|
||||||
fields: {
|
fields: {
|
||||||
orgName: organization.name,
|
orgName: organization.name,
|
||||||
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
|
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
|
||||||
loginUrl
|
loginUrl
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`
|
||||||
});
|
});
|
||||||
emailsSent++;
|
emailsSent++;
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError);
|
console.error(`enrollAudienceStudentProfilesInCourses enqueue error for ${email}:`, emailError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,18 +285,19 @@ async function enrollAudienceStudentProfilesInPrograms(
|
|||||||
const email = profileEmailMap.get(pair.profileId)!;
|
const email = profileEmailMap.get(pair.profileId)!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail('studentProgramWelcome', {
|
await enqueueTransactionalEmail('studentProgramWelcome', {
|
||||||
to: email,
|
to: email,
|
||||||
fields: {
|
fields: {
|
||||||
orgName: organization.name,
|
orgName: organization.name,
|
||||||
programName: programNameById.get(pair.programId) || 'Program',
|
programName: programNameById.get(pair.programId) || 'Program',
|
||||||
loginUrl
|
loginUrl
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
|
||||||
});
|
});
|
||||||
emailsSent++;
|
emailsSent++;
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError);
|
console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -385,7 +388,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
|||||||
const token = tokenByEmail.get(email)!;
|
const token = tokenByEmail.get(email)!;
|
||||||
try {
|
try {
|
||||||
const inviteLink = buildInviteLink(token);
|
const inviteLink = buildInviteLink(token);
|
||||||
const responses = await sendEmail('studentOrgInvite', {
|
await enqueueTransactionalEmail('studentOrgInvite', {
|
||||||
to: email,
|
to: email,
|
||||||
fields: {
|
fields: {
|
||||||
email,
|
email,
|
||||||
@@ -394,27 +397,17 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
|||||||
expiresAt: getExpiryLabel(expiresAt),
|
expiresAt: getExpiryLabel(expiresAt),
|
||||||
courseNames: accessNamesLabel
|
courseNames: accessNamesLabel
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `student-org-invite:${invite.id}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const allSuccessful = responses.every((r) => r.success);
|
// Optimistic — see comment in services/organization/invite.ts.
|
||||||
if (allSuccessful) {
|
|
||||||
return {
|
return {
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
email,
|
email,
|
||||||
success: true as const,
|
success: true as const,
|
||||||
error: undefined as string | undefined
|
error: undefined as string | undefined
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return {
|
|
||||||
inviteId: invite.id,
|
|
||||||
email,
|
|
||||||
success: false as const,
|
|
||||||
error: responses
|
|
||||||
.filter((r) => !r.success)
|
|
||||||
.map((r) => r.error || 'Unknown')
|
|
||||||
.join('; ')
|
|
||||||
};
|
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
const message = emailError instanceof Error ? emailError.message : 'Unknown email error';
|
||||||
return { inviteId: invite.id, email, success: false as const, error: message };
|
return { inviteId: invite.id, email, success: false as const, error: message };
|
||||||
@@ -685,7 +678,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
|||||||
let emailSent = false;
|
let emailSent = false;
|
||||||
try {
|
try {
|
||||||
const inviteLink = buildInviteLink(token);
|
const inviteLink = buildInviteLink(token);
|
||||||
const responses = await sendEmail('studentOrgInvite', {
|
await enqueueTransactionalEmail('studentOrgInvite', {
|
||||||
to: emailToUse,
|
to: emailToUse,
|
||||||
fields: {
|
fields: {
|
||||||
email: emailToUse,
|
email: emailToUse,
|
||||||
@@ -694,27 +687,23 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
|||||||
expiresAt: getExpiryLabel(expiresAt),
|
expiresAt: getExpiryLabel(expiresAt),
|
||||||
courseNames: accessNamesLabel
|
courseNames: accessNamesLabel
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `student-org-invite:${invite.id}`
|
||||||
});
|
});
|
||||||
const allSuccessful = responses.every((r) => r.success);
|
|
||||||
emailSent = allSuccessful;
|
emailSent = true;
|
||||||
|
// Optimistic EMAIL_SENT — worker handles retries; final failure flips
|
||||||
|
// email_delivery to `failed` for operator follow-up.
|
||||||
await createOrganizationInviteAudits([
|
await createOrganizationInviteAudits([
|
||||||
{
|
{
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED',
|
eventType: 'EMAIL_SENT',
|
||||||
actorProfileId: invitedByProfileId,
|
actorProfileId: invitedByProfileId,
|
||||||
targetEmail: emailToUse,
|
targetEmail: emailToUse,
|
||||||
ipAddress: null,
|
ipAddress: null,
|
||||||
userAgent: null,
|
userAgent: null,
|
||||||
metadata: allSuccessful
|
metadata: {}
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
error: responses
|
|
||||||
.filter((r) => !r.success)
|
|
||||||
.map((r) => r.error || 'Unknown')
|
|
||||||
.join('; ')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
|||||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||||
import { sendEmail } from '@cio/email';
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||||
|
|
||||||
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
|
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
|
||||||
@@ -244,7 +244,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
|
|||||||
const inviteLink = buildInviteLink(token);
|
const inviteLink = buildInviteLink(token);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responses = await sendEmail('inviteTeacher', {
|
await enqueueTransactionalEmail('inviteTeacher', {
|
||||||
to: email,
|
to: email,
|
||||||
fields: {
|
fields: {
|
||||||
email,
|
email,
|
||||||
@@ -253,25 +253,14 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
|
|||||||
roleName,
|
roleName,
|
||||||
expiresAt: getExpiryLabel(expiresAt),
|
expiresAt: getExpiryLabel(expiresAt),
|
||||||
inviteLink
|
inviteLink
|
||||||
}
|
},
|
||||||
|
idempotencyKey: `org-invite-teacher:${invite.id}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const allSuccessful = responses.every((response) => response.success);
|
// Record EMAIL_SENT optimistically — the worker retries up to 5 times,
|
||||||
if (!allSuccessful) {
|
// and a final failure flips the email_delivery row to `failed` for
|
||||||
const message = responses
|
// operator follow-up. EMAIL_FAILED here only means the enqueue itself
|
||||||
.filter((response) => !response.success)
|
// failed (DB outbox write or transient validation error).
|
||||||
.map((response) => response.error || 'Unknown email error')
|
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', {
|
|
||||||
actorProfileId: invitedByProfileId,
|
|
||||||
targetEmail: email,
|
|
||||||
metadata: { error: message }
|
|
||||||
});
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
|
await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', {
|
||||||
actorProfileId: invitedByProfileId,
|
actorProfileId: invitedByProfileId,
|
||||||
targetEmail: email
|
targetEmail: email
|
||||||
|
|||||||
@@ -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,
|
TSubmissionGradesUpdate,
|
||||||
TSubmissionUpdate
|
TSubmissionUpdate
|
||||||
} from '@cio/utils/validation/submission';
|
} from '@cio/utils/validation/submission';
|
||||||
import { buildEmailFromName, deliverEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueRawEmail } from '@api/services/jobs';
|
||||||
import {
|
import {
|
||||||
createSubmission,
|
createSubmission,
|
||||||
deleteSubmission,
|
deleteSubmission,
|
||||||
@@ -977,14 +978,13 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deliverEmail([
|
await enqueueRawEmail({
|
||||||
{
|
|
||||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||||
to: fullSubmission.groupmember.profile.email,
|
to: fullSubmission.groupmember.profile.email,
|
||||||
subject: 'Submission Update',
|
subject: 'Submission Update',
|
||||||
content
|
content,
|
||||||
}
|
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1035,18 +1035,14 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Send email to all tutors
|
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
|
||||||
const emailPromises = tutorsResult.map((tutor) => {
|
if (tutorEmails.length === 0) return;
|
||||||
if (!tutor.email) return Promise.resolve();
|
|
||||||
return deliverEmail([
|
|
||||||
{
|
|
||||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
|
||||||
to: tutor.email,
|
|
||||||
subject: `[Submitted]: ${exercise.title}`,
|
|
||||||
content
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(emailPromises);
|
await enqueueRawEmail({
|
||||||
|
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||||
|
to: tutorEmails,
|
||||||
|
subject: `[Submitted]: ${exercise.title}`,
|
||||||
|
content,
|
||||||
|
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@
|
|||||||
import { listWidgetCourses } from '@cio/db/queries/widget';
|
import { listWidgetCourses } from '@cio/db/queries/widget';
|
||||||
import { PLAN } from '@cio/utils/plans';
|
import { PLAN } from '@cio/utils/plans';
|
||||||
import {
|
import {
|
||||||
ZWidgetPayload,
|
buildWidgetPayload as buildWidgetPayloadShared,
|
||||||
getLayoutCourseLimit,
|
|
||||||
normalizeWidgetConfig,
|
normalizeWidgetConfig,
|
||||||
resolveWidgetPlanGatedFields,
|
resolveWidgetPlanGatedFields,
|
||||||
type TWidgetLayoutType,
|
type BuildWidgetPayloadCourse,
|
||||||
type TWidgetPayload,
|
type TWidgetPayload
|
||||||
type TWidgetPayloadCategory,
|
|
||||||
type TWidgetPayloadCourseTag
|
|
||||||
} from '@cio/utils/validation/widget';
|
} from '@cio/utils/validation/widget';
|
||||||
import type { TWidget } from '@db/types';
|
import type { TWidget } from '@db/types';
|
||||||
import * as csstree from 'css-tree';
|
import * as csstree from 'css-tree';
|
||||||
@@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string {
|
|||||||
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
|
return `wgt_${encodeBase58(bytes).slice(0, 16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
|
export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string {
|
||||||
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
|
if (!customCss.trim() || !planName || planName === PLAN.BASIC) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st
|
|||||||
return csstree.generate(ast);
|
return csstree.generate(ast);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
|
export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string {
|
||||||
if (customDomain) {
|
if (customDomain) {
|
||||||
return `https://${customDomain}`;
|
return `https://${customDomain}`;
|
||||||
}
|
}
|
||||||
@@ -174,6 +171,28 @@ export function getWidgetEmbedCode(publicKey: string): string {
|
|||||||
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
return `<div data-cio-widget="course-widget" data-widget-key="${publicKey}" data-api-base-url="${apiBaseUrl}"></div>\n<script async type="module" src="${scriptUrl}"></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrgCourse = Awaited<ReturnType<typeof getOrgCourses>>['items'][number];
|
||||||
|
|
||||||
|
export function formatCourseForWidget(
|
||||||
|
course: OrgCourse,
|
||||||
|
tags: BuildWidgetPayloadCourse['tags']
|
||||||
|
): BuildWidgetPayloadCourse {
|
||||||
|
return {
|
||||||
|
id: course.id,
|
||||||
|
slug: course.slug ?? course.id,
|
||||||
|
title: course.title,
|
||||||
|
description: course.description,
|
||||||
|
imageUrl: course.bannerImage || course.logo || null,
|
||||||
|
isPublished: course.isPublished,
|
||||||
|
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
|
||||||
|
lessonCount: course.lessonCount,
|
||||||
|
exerciseCount: course.exerciseCount ?? 0,
|
||||||
|
courseType: course.type,
|
||||||
|
createdAt: course.createdAt ?? new Date().toISOString(),
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildWidgetPayload(
|
export async function buildWidgetPayload(
|
||||||
widget: TWidget,
|
widget: TWidget,
|
||||||
overrideSelectedCourseIds?: string[]
|
overrideSelectedCourseIds?: string[]
|
||||||
@@ -184,216 +203,47 @@ export async function buildWidgetPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
|
const activePlan = await getActiveOrganizationPlan(widget.organizationId);
|
||||||
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, activePlan?.planName);
|
const planName = activePlan?.planName ?? null;
|
||||||
|
const normalizedConfig = normalizeWidgetConfig(widget.config as Record<string, unknown>, planName);
|
||||||
const selectedCourseIds =
|
const selectedCourseIds =
|
||||||
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
|
overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId);
|
||||||
const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
|
const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 });
|
||||||
const allPublishedCourses = allCourses.items.filter((course) => course.isPublished);
|
|
||||||
const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index]));
|
|
||||||
|
|
||||||
let resolvedCourses =
|
|
||||||
widget.selectionMode === 'manual'
|
|
||||||
? allCourses.items.filter((course) => manualOrderLookup.has(course.id))
|
|
||||||
: allPublishedCourses;
|
|
||||||
|
|
||||||
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
|
||||||
widget.organizationId,
|
widget.organizationId,
|
||||||
resolvedCourses.map((course) => course.id)
|
allCoursesResult.items.map((course) => course.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) {
|
const allCourses = allCoursesResult.items.map((course) =>
|
||||||
resolvedCourses = resolvedCourses.filter((course) => {
|
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
|
||||||
const courseTags = tagsByCourseId[course.id] ?? [];
|
|
||||||
const courseTagIds = new Set(courseTags.map((tag) => tag.id));
|
|
||||||
const includesRequired =
|
|
||||||
normalizedConfig.filters.includeTagIds.length === 0 ||
|
|
||||||
normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId));
|
|
||||||
const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId));
|
|
||||||
|
|
||||||
return includesRequired && excludesBlocked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widget.selectionMode === 'manual' || normalizedConfig.sortBy === 'manual') {
|
|
||||||
resolvedCourses = [...resolvedCourses].sort(
|
|
||||||
(courseA, courseB) =>
|
|
||||||
(manualOrderLookup.get(courseA.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
||||||
(manualOrderLookup.get(courseB.id) ?? Number.MAX_SAFE_INTEGER)
|
|
||||||
);
|
);
|
||||||
} else if (normalizedConfig.sortBy === 'title') {
|
|
||||||
resolvedCourses = [...resolvedCourses].sort((courseA, courseB) => courseA.title.localeCompare(courseB.title));
|
|
||||||
} else {
|
|
||||||
resolvedCourses = [...resolvedCourses].sort(
|
|
||||||
(courseA, courseB) => new Date(courseB.createdAt ?? '').getTime() - new Date(courseA.createdAt ?? '').getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutType = widget.layoutType as TWidgetLayoutType;
|
const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset);
|
||||||
const layoutOptions = normalizedConfig.layoutOptions;
|
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName);
|
||||||
|
|
||||||
// Hoist the configured "main" course to the front for layouts that have a hero slot,
|
|
||||||
// so the rest of the slice/limit logic can treat slot 0 as the featured course.
|
|
||||||
if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) {
|
|
||||||
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
|
|
||||||
const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId);
|
|
||||||
if (featuredIndex > 0) {
|
|
||||||
const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1);
|
|
||||||
resolvedCourses = [featuredCourse, ...resolvedCourses];
|
|
||||||
}
|
|
||||||
} else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) {
|
|
||||||
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
|
|
||||||
const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId);
|
|
||||||
if (mainIndex > 0) {
|
|
||||||
const [mainCourse] = resolvedCourses.splice(mainIndex, 1);
|
|
||||||
resolvedCourses = [mainCourse, ...resolvedCourses];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap the course list according to the active layout's limit. Category shelf
|
|
||||||
// builds its own grouped slices below from the already-filtered universe.
|
|
||||||
if (layoutType !== 'category_shelf') {
|
|
||||||
const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions);
|
|
||||||
resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1));
|
|
||||||
} else {
|
|
||||||
resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build payload-specific aggregates.
|
|
||||||
const heroCourseId =
|
|
||||||
layoutType === 'primary_course'
|
|
||||||
? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null)
|
|
||||||
: layoutType === 'editorial_spotlight'
|
|
||||||
? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
|
|
||||||
const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId);
|
|
||||||
|
|
||||||
const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset);
|
|
||||||
const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName);
|
|
||||||
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
|
const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain);
|
||||||
|
|
||||||
return ZWidgetPayload.parse({
|
return buildWidgetPayloadShared({
|
||||||
version: 'v1',
|
widget: {
|
||||||
widgetId: widget.id,
|
id: widget.id,
|
||||||
publicKey: widget.publicKey,
|
publicKey: widget.publicKey,
|
||||||
|
layoutType: widget.layoutType,
|
||||||
|
selectionMode: widget.selectionMode,
|
||||||
|
config: normalizedConfig
|
||||||
|
},
|
||||||
organization: {
|
organization: {
|
||||||
id: organization.id,
|
id: organization.id,
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
siteName: organization.siteName,
|
siteName: organization.siteName ?? '',
|
||||||
customDomain: organization.customDomain
|
customDomain: organization.customDomain
|
||||||
},
|
},
|
||||||
layoutType,
|
orgBaseUrl,
|
||||||
selectionMode: widget.selectionMode,
|
allCourses,
|
||||||
design: {
|
selectedCourseIds,
|
||||||
...normalizedConfig,
|
|
||||||
advanced: {
|
|
||||||
...normalizedConfig.advanced,
|
|
||||||
customCss: sanitizedCustomCss
|
|
||||||
}
|
|
||||||
},
|
|
||||||
planGatedFields,
|
planGatedFields,
|
||||||
labels: {
|
planName,
|
||||||
loadMoreLabel: normalizedConfig.content.loadMoreLabel,
|
sanitizedCustomCss
|
||||||
poweredByLabel: 'Powered by Classroomio'
|
|
||||||
},
|
|
||||||
courses: resolvedCourses.map((course) => ({
|
|
||||||
id: course.id,
|
|
||||||
slug: course.slug ?? course.id,
|
|
||||||
title: course.title,
|
|
||||||
description: course.description,
|
|
||||||
imageUrl: course.bannerImage || course.logo || null,
|
|
||||||
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
|
|
||||||
lessonCount: course.lessonCount,
|
|
||||||
exerciseCount: course.exerciseCount ?? 0,
|
|
||||||
rating: null,
|
|
||||||
ratingCount: 0,
|
|
||||||
courseType: course.type,
|
|
||||||
createdAt: course.createdAt ?? new Date().toISOString(),
|
|
||||||
tags: tagsByCourseId[course.id] ?? [],
|
|
||||||
featured: heroCourseId ? course.id === heroCourseId : false,
|
|
||||||
url: `${orgBaseUrl}/course/${course.slug ?? course.id}`
|
|
||||||
})),
|
|
||||||
tagPool,
|
|
||||||
categories,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTagPool(
|
|
||||||
layoutType: TWidgetLayoutType,
|
|
||||||
config: ReturnType<typeof normalizeWidgetConfig>,
|
|
||||||
courses: Array<{ id: string }>,
|
|
||||||
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
|
|
||||||
): TWidgetPayloadCourseTag[] {
|
|
||||||
if (layoutType !== 'tag_filter') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTagsFromCourses = new Map<string, TWidgetPayloadCourseTag>();
|
|
||||||
for (const course of courses) {
|
|
||||||
for (const tag of tagsByCourseId[course.id] ?? []) {
|
|
||||||
if (!allTagsFromCourses.has(tag.id)) {
|
|
||||||
allTagsFromCourses.set(tag.id, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.layoutOptions.tagFilter.tagSource === 'manual') {
|
|
||||||
const allowedIds = new Set(config.layoutOptions.tagFilter.manualTagIds);
|
|
||||||
return [...allTagsFromCourses.values()].filter((tag) => allowedIds.has(tag.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...allTagsFromCourses.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCategoriesFromShelf(
|
|
||||||
layoutType: TWidgetLayoutType,
|
|
||||||
config: ReturnType<typeof normalizeWidgetConfig>,
|
|
||||||
courses: Array<{ id: string }>,
|
|
||||||
tagsByCourseId: Record<string, TWidgetPayloadCourseTag[]>
|
|
||||||
): TWidgetPayloadCategory[] {
|
|
||||||
if (layoutType !== 'category_shelf') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedCategoryIds = config.layoutOptions.categoryShelf.categoryTagIds;
|
|
||||||
if (orderedCategoryIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse-lookup tag info from the tags-by-course map.
|
|
||||||
const tagsById = new Map<string, TWidgetPayloadCourseTag>();
|
|
||||||
for (const tags of Object.values(tagsByCourseId)) {
|
|
||||||
for (const tag of tags) {
|
|
||||||
tagsById.set(tag.id, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory;
|
|
||||||
|
|
||||||
return orderedCategoryIds
|
|
||||||
.map((tagId) => {
|
|
||||||
const tagInfo = tagsById.get(tagId);
|
|
||||||
if (!tagInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingCourses = courses.filter((course) =>
|
|
||||||
(tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tagId,
|
|
||||||
name: tagInfo.name,
|
|
||||||
slug: tagInfo.slug,
|
|
||||||
color: tagInfo.color,
|
|
||||||
courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id)
|
|
||||||
} satisfies TWidgetPayloadCategory;
|
|
||||||
})
|
|
||||||
.filter((entry): entry is TWidgetPayloadCategory => entry !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWidgetAvailableEditorData(orgId: string) {
|
export async function listWidgetAvailableEditorData(orgId: string) {
|
||||||
const [coursesResult, tagGroups, activePlan] = await Promise.all([
|
const [coursesResult, tagGroups, activePlan] = await Promise.all([
|
||||||
getOrgCourses({ orgId, limit: 200 }),
|
getOrgCourses({ orgId, limit: 200 }),
|
||||||
@@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableCourses: coursesResult.items.map((course) => ({
|
availableCourses: coursesResult.items.map((course) =>
|
||||||
id: course.id,
|
formatCourseForWidget(course, tagsByCourseId[course.id] ?? [])
|
||||||
slug: course.slug ?? course.id,
|
),
|
||||||
title: course.title,
|
|
||||||
description: course.description,
|
|
||||||
imageUrl: course.bannerImage || course.logo || null,
|
|
||||||
isPublished: course.isPublished,
|
|
||||||
price: course.cost ? `${course.currency} ${course.cost}` : 'Free',
|
|
||||||
lessonCount: course.lessonCount,
|
|
||||||
exerciseCount: course.exerciseCount ?? 0,
|
|
||||||
courseType: course.type,
|
|
||||||
createdAt: course.createdAt ?? new Date().toISOString(),
|
|
||||||
tags: tagsByCourseId[course.id] ?? []
|
|
||||||
})),
|
|
||||||
availableTags: tagGroups.flatMap((group) =>
|
availableTags: tagGroups.flatMap((group) =>
|
||||||
group.tags.map((tag) => ({
|
group.tags.map((tag) => ({
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
@@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) {
|
|||||||
groupId: group.id
|
groupId: group.id
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio')
|
planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'),
|
||||||
|
planName: activePlan?.planName ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ import {
|
|||||||
getDefaultWidgetConfig,
|
getDefaultWidgetConfig,
|
||||||
normalizeWidgetConfig,
|
normalizeWidgetConfig,
|
||||||
type TCreateWidget,
|
type TCreateWidget,
|
||||||
type TPreviewWidget,
|
|
||||||
type TRollbackWidget,
|
type TRollbackWidget,
|
||||||
type TUpdateWidget
|
type TUpdateWidget
|
||||||
} from '@cio/utils/validation/widget';
|
} from '@cio/utils/validation/widget';
|
||||||
|
|
||||||
|
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||||
import {
|
import {
|
||||||
buildWidgetPayload,
|
buildWidgetPayload,
|
||||||
generateWidgetPublicKey,
|
generateWidgetPublicKey,
|
||||||
|
getCourseBaseUrl,
|
||||||
getCourseWidgetScriptUrl,
|
getCourseWidgetScriptUrl,
|
||||||
getWidgetEmbedCode,
|
getWidgetEmbedCode,
|
||||||
listWidgetAvailableEditorData
|
listWidgetAvailableEditorData
|
||||||
@@ -82,26 +83,37 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin
|
|||||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([
|
const organization = await getOrganizationById(orgId);
|
||||||
|
if (!organization) {
|
||||||
|
throw new AppError('Organization not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedCourses, versions, editorData] = await Promise.all([
|
||||||
listWidgetCourses(widget.id),
|
listWidgetCourses(widget.id),
|
||||||
listWidgetVersions(orgId, widget.id),
|
listWidgetVersions(orgId, widget.id),
|
||||||
listWidgetAvailableEditorData(orgId),
|
listWidgetAvailableEditorData(orgId)
|
||||||
buildWidgetPayload(widget)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
widget: {
|
widget: {
|
||||||
...widget,
|
...widget,
|
||||||
config: normalizeWidgetConfig(widget.config as Record<string, unknown>),
|
config: normalizeWidgetConfig(widget.config as Record<string, unknown>, editorData.planName),
|
||||||
selectedCourseIds: selectedCourses.map((course) => course.courseId),
|
selectedCourseIds: selectedCourses.map((course) => course.courseId),
|
||||||
embedCode: getWidgetEmbedCode(widget.publicKey),
|
embedCode: getWidgetEmbedCode(widget.publicKey),
|
||||||
publicScriptUrl: getCourseWidgetScriptUrl()
|
publicScriptUrl: getCourseWidgetScriptUrl()
|
||||||
},
|
},
|
||||||
|
organization: {
|
||||||
|
id: organization.id,
|
||||||
|
name: organization.name,
|
||||||
|
siteName: organization.siteName ?? '',
|
||||||
|
customDomain: organization.customDomain
|
||||||
|
},
|
||||||
|
orgBaseUrl: getCourseBaseUrl(organization.siteName ?? '', organization.customDomain),
|
||||||
availableCourses: editorData.availableCourses,
|
availableCourses: editorData.availableCourses,
|
||||||
availableTags: editorData.availableTags,
|
availableTags: editorData.availableTags,
|
||||||
previewPayload,
|
|
||||||
versions,
|
versions,
|
||||||
planGatedFields: editorData.planGatedFields
|
planGatedFields: editorData.planGatedFields,
|
||||||
|
planName: editorData.planName
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
@@ -285,34 +297,6 @@ export async function rollbackOrganizationWidget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) {
|
|
||||||
try {
|
|
||||||
const widget = await getWidgetById(orgId, widgetId);
|
|
||||||
if (!widget) {
|
|
||||||
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const overriddenWidget = {
|
|
||||||
...widget,
|
|
||||||
layoutType: data.layoutType ?? widget.layoutType,
|
|
||||||
selectionMode: data.selectionMode ?? widget.selectionMode,
|
|
||||||
config: data.config ?? widget.config
|
|
||||||
};
|
|
||||||
|
|
||||||
return buildWidgetPayload(overriddenWidget, data.selectedCourseIds);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AppError) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AppError(
|
|
||||||
error instanceof Error ? error.message : 'Failed to build widget preview',
|
|
||||||
ErrorCodes.WIDGET_FETCH_FAILED,
|
|
||||||
500
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPublishedWidgetPayload(publicKey: string) {
|
export async function getPublishedWidgetPayload(publicKey: string) {
|
||||||
try {
|
try {
|
||||||
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);
|
const payload = await getPublishedWidgetPayloadByPublicKey(publicKey);
|
||||||
|
|||||||
@@ -1,114 +1,66 @@
|
|||||||
import type { TCertificateDownload } from '@cio/utils/validation/course';
|
import {
|
||||||
import { getCloudflarePdfBuffer } from '@api/utils/cloudflare';
|
DEFAULT_CERTIFICATE_DESIGN,
|
||||||
|
renderCertificate,
|
||||||
|
resolveTemplateId,
|
||||||
|
type CertificateDesign,
|
||||||
|
type CertificateRenderData
|
||||||
|
} from '@cio/certificates';
|
||||||
|
|
||||||
export interface CertificateGenerationResult {
|
import { getCloudflarePdfBuffer, getCloudflarePngBuffer } from '@api/utils/cloudflare';
|
||||||
success: boolean;
|
|
||||||
buffer?: Buffer;
|
export interface CertificateRenderInput {
|
||||||
error?: string;
|
design: CertificateDesign;
|
||||||
|
data: CertificateRenderData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateCertificate = async (data: TCertificateDownload) => {
|
/**
|
||||||
const { html, styles } = generateCertificateHtml(data);
|
* Coerces a stored `course.certificate` JSONB blob (legacy or current) into a
|
||||||
return getCloudflarePdfBuffer(html, styles);
|
* complete `CertificateDesign` suitable for `renderCertificate`.
|
||||||
};
|
*/
|
||||||
|
export function resolveCertificateDesign(stored: unknown): CertificateDesign {
|
||||||
|
const blob = stored && typeof stored === 'object' ? (stored as Record<string, unknown>) : {};
|
||||||
|
const legacyTheme = typeof blob.theme === 'string' ? (blob.theme as string) : undefined;
|
||||||
|
const storedDesign =
|
||||||
|
blob.design && typeof blob.design === 'object' ? (blob.design as Partial<CertificateDesign>) : undefined;
|
||||||
|
|
||||||
function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } {
|
const templateId = resolveTemplateId(storedDesign?.templateId ?? legacyTheme);
|
||||||
const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data;
|
|
||||||
|
|
||||||
const html = `
|
const accentColor =
|
||||||
<html>
|
storedDesign?.accentColor && /^#[0-9a-fA-F]{6}$/.test(storedDesign.accentColor)
|
||||||
<head>
|
? storedDesign.accentColor
|
||||||
<meta charset="utf-8">
|
: DEFAULT_CERTIFICATE_DESIGN.accentColor;
|
||||||
<title>Certificate of Completion</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="certificate">
|
|
||||||
<div class="header">
|
|
||||||
<img src="${orgLogoUrl}" alt="${orgName} Logo" class="logo">
|
|
||||||
<h1 class="title">Certificate of Completion</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<h2 class="student-name">${studentName}</h2>
|
|
||||||
<p class="course-name">${courseName}</p>
|
|
||||||
<p class="description">${courseDescription}</p>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<div class="signature">
|
|
||||||
<p>${facilitator || 'Course Facilitator'}</p>
|
|
||||||
<p>${orgName}</p>
|
|
||||||
</div>
|
|
||||||
<p class="date">${new Date().toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const styles = `
|
const storedSignatories = Array.isArray(storedDesign?.signatories) ? storedDesign?.signatories : undefined;
|
||||||
body {
|
|
||||||
font-family: 'Arial', sans-serif;
|
const signatories: CertificateDesign['signatories'] = [
|
||||||
margin: 0;
|
{
|
||||||
padding: 0;
|
name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||||
background-color: #f5f5f5;
|
role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||||
|
role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||||
}
|
}
|
||||||
.certificate {
|
];
|
||||||
width: 1123px;
|
|
||||||
height: 794px;
|
return {
|
||||||
margin: 0 auto;
|
templateId,
|
||||||
background-color: white;
|
accentColor,
|
||||||
position: relative;
|
subtitle: storedDesign?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
descriptionOverride: storedDesign?.descriptionOverride,
|
||||||
}
|
signatories,
|
||||||
.header {
|
idFormat: storedDesign?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
max-width: 200px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 36px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
.student-name {
|
|
||||||
font-size: 48px;
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.course-name {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #34495e;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #7f8c8d;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 40px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.signature {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.date {
|
|
||||||
margin-top: 10px;
|
|
||||||
color: #95a5a6;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const pdf = {
|
|
||||||
html: html,
|
|
||||||
styles: styles
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return pdf;
|
export async function generateCertificatePdf(input: CertificateRenderInput) {
|
||||||
|
const { html, styles } = renderCertificate(input.design, input.data);
|
||||||
|
|
||||||
|
return getCloudflarePdfBuffer(html, styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCertificatePng(input: CertificateRenderInput) {
|
||||||
|
const { html, styles } = renderCertificate(input.design, input.data);
|
||||||
|
|
||||||
|
return getCloudflarePngBuffer(html, styles);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,37 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => {
|
|||||||
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
|
throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint.
|
||||||
|
* The viewport is fixed to 1100x780 to match the certificate canvas.
|
||||||
|
*/
|
||||||
|
export const getCloudflarePngBuffer = async (html: string, styles?: string) => {
|
||||||
|
console.log('Generating PNG with Cloudflare API...');
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/screenshot`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
html,
|
||||||
|
addStyleTag: styles ? [{ content: styles }] : undefined,
|
||||||
|
viewport: { width: 1100, height: 780, deviceScaleFactor: 2 },
|
||||||
|
screenshotOptions: { type: 'png', omitBackground: false, fullPage: false }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('PNG response status:', response.status);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PNG:', error);
|
||||||
|
throw new Error(error instanceof Error ? error.message : 'Failed to generate PNG');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
} from '@cio/db/queries/course/course';
|
} from '@cio/db/queries/course/course';
|
||||||
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
|
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { buildEmailFromName, sendEmail } from '@cio/email';
|
import { buildEmailFromName } from '@cio/email';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
|
|
||||||
export function calcCourseProgressPercent(params: {
|
export function calcCourseProgressPercent(params: {
|
||||||
lessonsCompleted: number;
|
lessonsCompleted: number;
|
||||||
@@ -69,7 +70,7 @@ export function scheduleCertificationCompletionWork(params: {
|
|||||||
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
|
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
|
||||||
const certificateUrl = `${base}/courses/${courseId}/certificates`;
|
const certificateUrl = `${base}/courses/${courseId}/certificates`;
|
||||||
|
|
||||||
await sendEmail('studentCourseCompletion', {
|
await enqueueTransactionalEmail('studentCourseCompletion', {
|
||||||
to: studentEmail,
|
to: studentEmail,
|
||||||
fields: {
|
fields: {
|
||||||
orgName: courseRow.orgName,
|
orgName: courseRow.orgName,
|
||||||
@@ -78,7 +79,8 @@ export function scheduleCertificationCompletionWork(params: {
|
|||||||
certificateUrl,
|
certificateUrl,
|
||||||
customMessage: courseRow.certificate?.emailMessage ?? null
|
customMessage: courseRow.certificate?.emailMessage ?? null
|
||||||
},
|
},
|
||||||
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
|
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
|
||||||
|
idempotencyKey: `course-completion:${groupMemberId}`
|
||||||
});
|
});
|
||||||
|
|
||||||
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
|
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
|
||||||
|
|||||||
@@ -132,3 +132,15 @@ export const agentUploadKeyGenerator = (c: Context): string => {
|
|||||||
const baseKey = userKeyGenerator(c);
|
const baseKey = userKeyGenerator(c);
|
||||||
return `${baseKey}:agent:upload`;
|
return `${baseKey}:agent:upload`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis keys for engagement analytics read endpoints (landing-stats, funnel,
|
||||||
|
* country breakdown, time-to-enrollment). Value: JSON.
|
||||||
|
*/
|
||||||
|
export function dashAnalyticsKey(route: string, orgId: string, days: number, extra?: string): string {
|
||||||
|
const suffix = extra ? `:${extra}` : '';
|
||||||
|
return `dash:analytics:${route}:${orgId}:${days}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TTL for engagement analytics caches (10 min). */
|
||||||
|
export const DASH_ANALYTICS_TTL_SECONDS = 600;
|
||||||
|
|||||||
@@ -156,6 +156,22 @@ export async function generateUploadPresignedUrl(
|
|||||||
return presignedUrl;
|
return presignedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
|
||||||
|
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
|
||||||
|
|
||||||
|
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
|
||||||
|
const client = getPresignS3Client();
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: key
|
||||||
|
}) as GetSignedUrlParameters[1];
|
||||||
|
|
||||||
|
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
|
||||||
|
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates presigned URLs for video download
|
* Generates presigned URLs for video download
|
||||||
* @param keys Array of video keys
|
* @param keys Array of video keys
|
||||||
|
|||||||
@@ -25,7 +25,11 @@
|
|||||||
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
|
"@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"],
|
||||||
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
|
"@cio/utils/*": ["./node_modules/@cio/utils/dist/*"],
|
||||||
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
|
"@cio/email/*": ["./node_modules/@cio/email/dist/*"],
|
||||||
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"]
|
"@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"],
|
||||||
|
"@cio/jobs": ["./node_modules/@cio/jobs/dist/index.d.ts"],
|
||||||
|
"@cio/jobs/*": ["./node_modules/@cio/jobs/dist/*"],
|
||||||
|
"@cio/analytics": ["./node_modules/@cio/analytics/dist/index.d.ts"],
|
||||||
|
"@cio/analytics/*": ["./node_modules/@cio/analytics/dist/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
@@ -33,7 +37,9 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/db" },
|
{ "path": "../../packages/db" },
|
||||||
{ "path": "../../packages/utils" },
|
{ "path": "../../packages/utils" },
|
||||||
{ "path": "../../packages/email" }
|
{ "path": "../../packages/email" },
|
||||||
|
{ "path": "../../packages/jobs" },
|
||||||
|
{ "path": "../../packages/analytics" }
|
||||||
],
|
],
|
||||||
"tsc-alias": {
|
"tsc-alias": {
|
||||||
"resolveFullPaths": true,
|
"resolveFullPaths": true,
|
||||||
|
|||||||
@@ -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": {
|
"dependencies": {
|
||||||
"@ai-sdk/svelte": "^4.0.158",
|
"@ai-sdk/svelte": "^4.0.158",
|
||||||
"@better-auth/sso": "1.4.18",
|
"@better-auth/sso": "1.4.18",
|
||||||
|
"@cio/ai-assistant": "workspace:*",
|
||||||
|
"@cio/analytics": "workspace:*",
|
||||||
"@cio/api": "workspace:*",
|
"@cio/api": "workspace:*",
|
||||||
|
"@cio/certificates": "workspace:*",
|
||||||
"@cio/db": "workspace:*",
|
"@cio/db": "workspace:*",
|
||||||
"@cio/question-types": "workspace:*",
|
"@cio/question-types": "workspace:*",
|
||||||
"@cio/ui": "workspace:*",
|
"@cio/ui": "workspace:*",
|
||||||
|
|||||||
@@ -114,11 +114,11 @@ html .dark {
|
|||||||
.prose p,
|
.prose p,
|
||||||
.prose li,
|
.prose li,
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
@apply leading-7;
|
@apply leading-relaxed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
@apply my-0;
|
@apply mb-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
@@ -236,7 +236,7 @@ body {
|
|||||||
|
|
||||||
iframe.iframe {
|
iframe.iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 450px;
|
height: 450px;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ import { type Handle, redirect } from '@sveltejs/kit';
|
|||||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||||
import { ROUTE } from '$lib/utils/constants/routes';
|
import { ROUTE } from '$lib/utils/constants/routes';
|
||||||
|
|
||||||
|
const ANALYTICS_SESSION_COOKIE = 'cio_aid';
|
||||||
|
const ANALYTICS_SESSION_MAX_AGE = 60 * 60 * 24 * 90;
|
||||||
|
|
||||||
|
function ensureAnalyticsSessionCookie(cookies: Parameters<Handle>[0]['event']['cookies']) {
|
||||||
|
if (cookies.get(ANALYTICS_SESSION_COOKIE)) return;
|
||||||
|
|
||||||
|
cookies.set(ANALYTICS_SESSION_COOKIE, crypto.randomUUID(), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
maxAge: ANALYTICS_SESSION_MAX_AGE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const handle: Handle = async (args) => {
|
export const handle: Handle = async (args) => {
|
||||||
const { event } = args;
|
const { event } = args;
|
||||||
const sessionData = await getSessionData(event.cookies);
|
const sessionData = await getSessionData(event.cookies);
|
||||||
@@ -13,6 +28,10 @@ export const handle: Handle = async (args) => {
|
|||||||
event.locals = sessionData;
|
event.locals = sessionData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event.url.pathname.includes('/api')) {
|
||||||
|
ensureAnalyticsSessionCookie(event.cookies);
|
||||||
|
}
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
if (event.url.pathname.includes('/api')) {
|
if (event.url.pathname.includes('/api')) {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type {
|
||||||
|
AccountUsage,
|
||||||
|
AccountWorkspaceLimits,
|
||||||
|
AccountWorkspaceList,
|
||||||
|
CreateAccountWorkspaceRequest,
|
||||||
|
DeleteAccountWorkspaceRequest,
|
||||||
|
GetAccountUsageRequest,
|
||||||
|
ListAccountWorkspacesRequest
|
||||||
|
} from '../utils/types';
|
||||||
|
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||||
|
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
|
||||||
|
|
||||||
|
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||||
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
|
||||||
|
class AccountApi extends BaseApiWithErrors {
|
||||||
|
workspaces = $state<AccountWorkspaceList>([]);
|
||||||
|
limits = $state<AccountWorkspaceLimits | null>(null);
|
||||||
|
usage = $state<AccountUsage | null>(null);
|
||||||
|
|
||||||
|
async listWorkspaces() {
|
||||||
|
return this.execute<ListAccountWorkspacesRequest>({
|
||||||
|
requestFn: () => classroomio.account.workspaces.$get(),
|
||||||
|
logContext: 'fetching account workspaces',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
this.workspaces = response.data.workspaces;
|
||||||
|
this.limits = response.data.limits;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUsage() {
|
||||||
|
return this.execute<GetAccountUsageRequest>({
|
||||||
|
requestFn: () => classroomio.account.usage.$get(),
|
||||||
|
logContext: 'fetching account usage',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
this.usage = response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorkspace(fields: TCreateWorkspace) {
|
||||||
|
const parsed = ZCreateWorkspace.safeParse(fields);
|
||||||
|
if (!parsed.success) {
|
||||||
|
this.errors = mapZodErrorsToTranslations(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.execute<CreateAccountWorkspaceRequest>({
|
||||||
|
requestFn: () => classroomio.account.workspaces.$post({ json: parsed.data }),
|
||||||
|
logContext: 'creating workspace',
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
this.workspaces = [...this.workspaces, response.data];
|
||||||
|
snackbar.success('snackbar.account.workspaceCreated');
|
||||||
|
await this.listWorkspaces();
|
||||||
|
},
|
||||||
|
onError: (result) => {
|
||||||
|
if (typeof result === 'object' && 'code' in result) {
|
||||||
|
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWorkspace(workspaceId: string) {
|
||||||
|
return this.execute<DeleteAccountWorkspaceRequest>({
|
||||||
|
requestFn: () => classroomio.account.workspaces[':workspaceId'].$delete({ param: { workspaceId } }),
|
||||||
|
logContext: 'deleting workspace',
|
||||||
|
onSuccess: () => {
|
||||||
|
this.workspaces = this.workspaces.filter((row) => row.id !== workspaceId);
|
||||||
|
snackbar.success('snackbar.account.workspaceDeleted');
|
||||||
|
},
|
||||||
|
onError: (result) => {
|
||||||
|
if (typeof result === 'object' && 'code' in result) {
|
||||||
|
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountApi = new AccountApi();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as WorkspacesPage } from './pages/workspaces.svelte';
|
||||||
|
export { accountApi } from './api/account.svelte';
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Badge } from '@cio/ui/base/badge';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { Input } from '@cio/ui/base/input';
|
||||||
|
import { Spinner } from '@cio/ui/base/spinner';
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
|
import * as Field from '@cio/ui/base/field';
|
||||||
|
|
||||||
|
import { accountApi } from '$features/account/api/account.svelte';
|
||||||
|
import { currentOrg, isPrimaryWorkspace } from '$lib/utils/store/org';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
|
let isCreateOpen = $state(false);
|
||||||
|
let formName = $state('');
|
||||||
|
let formSiteName = $state('');
|
||||||
|
let confirmDeleteId = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!$currentOrg.id) return;
|
||||||
|
|
||||||
|
accountApi.listWorkspaces();
|
||||||
|
accountApi.loadUsage();
|
||||||
|
});
|
||||||
|
|
||||||
|
const limits = $derived(accountApi.limits);
|
||||||
|
const usage = $derived(accountApi.usage);
|
||||||
|
const workspaces = $derived(accountApi.workspaces);
|
||||||
|
|
||||||
|
const ctaState = $derived.by(() => {
|
||||||
|
if (!limits) return { kind: 'loading' as const };
|
||||||
|
|
||||||
|
if (limits.requiresUpgrade) {
|
||||||
|
return { kind: 'upgrade' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!limits.canCreate) {
|
||||||
|
return { kind: 'limit' as const, allowance: limits.allowance };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'create' as const, used: limits.used, allowance: limits.allowance };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
await accountApi.createWorkspace({ name: formName, siteName: formSiteName });
|
||||||
|
|
||||||
|
if (accountApi.success) {
|
||||||
|
formName = '';
|
||||||
|
formSiteName = '';
|
||||||
|
isCreateOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
await accountApi.deleteWorkspace(id);
|
||||||
|
confirmDeleteId = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !$isPrimaryWorkspace}
|
||||||
|
<div class="ui:text-muted-foreground rounded border p-4">
|
||||||
|
{$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Field.Group class="w-full max-w-2xl px-2">
|
||||||
|
{#if usage}
|
||||||
|
<Field.Set>
|
||||||
|
<div class="rounded border p-4">
|
||||||
|
<p class="text-sm">
|
||||||
|
{$t('account.workspaces.usage_banner', {
|
||||||
|
learners: usage.learnerCount,
|
||||||
|
tokens: usage.tokensUsedThisMonth,
|
||||||
|
workspaces: usage.workspaceCount
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Field.Set>
|
||||||
|
<Field.Separator />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Field.Set>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Field.Legend>{$t('account.workspaces.page_title')}</Field.Legend>
|
||||||
|
|
||||||
|
{#if ctaState.kind === 'create'}
|
||||||
|
<Button onclick={() => (isCreateOpen = true)}>
|
||||||
|
{$t('account.workspaces.create_cta')}
|
||||||
|
<span class="ui:text-muted-foreground ml-2">
|
||||||
|
{$t('account.workspaces.create_cta_used', { used: ctaState.used, allowance: ctaState.allowance })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{:else if ctaState.kind === 'limit'}
|
||||||
|
<Button disabled variant="outline">
|
||||||
|
{$t('account.workspaces.create_cta_disabled_limit', { allowance: ctaState.allowance })}
|
||||||
|
</Button>
|
||||||
|
{:else if ctaState.kind === 'upgrade'}
|
||||||
|
<Button variant="outline" href="/pricing">
|
||||||
|
{$t('account.workspaces.create_cta_disabled_upgrade')}
|
||||||
|
<span class="ml-2 underline">{$t('account.workspaces.upgrade_link')}</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field.Group>
|
||||||
|
{#if accountApi.isLoading && workspaces.length === 0}
|
||||||
|
<Spinner class="size-10! text-blue-700!" />
|
||||||
|
{:else}
|
||||||
|
{#each workspaces as workspace}
|
||||||
|
<Field.Field>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<p class="text-sm font-medium">{workspace.name}</p>
|
||||||
|
<span class="ui:text-muted-foreground text-xs">{workspace.siteName}</span>
|
||||||
|
{#if workspace.isPrimary}
|
||||||
|
<Badge variant="secondary">{$t('account.workspaces.primary_badge')}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if workspace.readOnlyUntil}
|
||||||
|
<Badge variant="outline" class="text-yellow-700">{$t('account.workspaces.read_only_badge')}</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !workspace.isPrimary}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="text-red-500 hover:text-red-700"
|
||||||
|
onclick={() => (confirmDeleteId = workspace.id)}
|
||||||
|
>
|
||||||
|
{$t('account.workspaces.delete_action')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Field.Field>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Field.Group>
|
||||||
|
</Field.Set>
|
||||||
|
</Field.Group>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={isCreateOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$t('account.workspaces.create_modal_title')}</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<Field.Group>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>{$t('account.workspaces.name_label')}</Field.Label>
|
||||||
|
<Input bind:value={formName} />
|
||||||
|
{#if accountApi.errors.name}
|
||||||
|
<Field.Error>{accountApi.errors.name}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>{$t('account.workspaces.site_name_label')}</Field.Label>
|
||||||
|
<Input bind:value={formSiteName} />
|
||||||
|
{#if accountApi.errors.siteName}
|
||||||
|
<Field.Error>{accountApi.errors.siteName}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Group>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (isCreateOpen = false)}>{$t('account.workspaces.cancel')}</Button>
|
||||||
|
<Button onclick={onCreate} loading={accountApi.isLoading} disabled={accountApi.isLoading}>
|
||||||
|
{$t('account.workspaces.create_modal_submit')}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root open={confirmDeleteId !== null} onOpenChange={(open) => !open && (confirmDeleteId = null)}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$t('account.workspaces.delete_confirm_title')}</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
<p class="ui:text-muted-foreground">{$t('account.workspaces.delete_confirm_body')}</p>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (confirmDeleteId = null)}>{$t('account.workspaces.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onclick={() => confirmDeleteId && onDelete(confirmDeleteId)}
|
||||||
|
loading={accountApi.isLoading}
|
||||||
|
disabled={accountApi.isLoading}
|
||||||
|
>
|
||||||
|
{$t('account.workspaces.delete_action')}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||||
|
|
||||||
|
export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces.$get;
|
||||||
|
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
|
||||||
|
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
|
||||||
|
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
|
||||||
|
|
||||||
|
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
|
||||||
|
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
|
||||||
|
|
||||||
|
export type AccountWorkspaceList = ListSuccess['data']['workspaces'];
|
||||||
|
export type AccountWorkspaceLimits = ListSuccess['data']['limits'];
|
||||||
|
export type AccountWorkspace = AccountWorkspaceList[number];
|
||||||
|
export type AccountUsage = UsageSuccess['data'];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||||
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import type { GenerateTextRequest } from '../utils/types';
|
||||||
|
|
||||||
|
export class TextGenerationApi extends BaseApiWithErrors {
|
||||||
|
async generate(
|
||||||
|
prompt: string,
|
||||||
|
tone: string,
|
||||||
|
format: 'plain' | 'html' = 'plain',
|
||||||
|
context?: string,
|
||||||
|
courseId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
let generatedText: string | null = null;
|
||||||
|
|
||||||
|
await this.execute<GenerateTextRequest>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.agent['generate-text'].$post({
|
||||||
|
json: { prompt, tone: tone as 'professional' | 'casual' | 'expert' | 'friendly', format, context, courseId }
|
||||||
|
}),
|
||||||
|
logContext: 'generating text',
|
||||||
|
onSuccess: (response) => {
|
||||||
|
generatedText = response.data.text;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
snackbar.error('Failed to generate text. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return generatedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const textGenerationApi = new TextGenerationApi();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AIGeneratePopover } from '@cio/ui/custom/ai-generate-popover';
|
||||||
|
import { textGenerationApi } from '../api/text-generation.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onInsert: (text: string) => void;
|
||||||
|
context?: string;
|
||||||
|
format?: 'plain' | 'html';
|
||||||
|
courseId?: string;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onInsert, context, format = 'plain', courseId, align = 'right' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AIGeneratePopover
|
||||||
|
{align}
|
||||||
|
onGenerate={async (prompt, tone) => {
|
||||||
|
const text = await textGenerationApi.generate(prompt, tone, format, context, courseId);
|
||||||
|
if (text) onInsert(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||||
|
|
||||||
|
export type GenerateTextRequest = (typeof classroomio.agent)['generate-text']['$post'];
|
||||||
|
export type GenerateTextSuccess = Extract<InferResponseType<GenerateTextRequest>, { success: true }>;
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
|
import ChatHeader from '$features/ai-assistant/chat-header.svelte';
|
||||||
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
|
import ChatMessageList from '$features/ai-assistant/chat-message-list.svelte';
|
||||||
import ChatInput from '$features/ai-assistant/chat-input.svelte';
|
import ChatInput from '$features/ai-assistant/chat-input.svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { getToolLabel, getToolResultLabel, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
import { getCompletedToolLine, getPendingToolLine, MUTATION_TOOLS } from '$features/ai-assistant/utils/tool-labels';
|
||||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
||||||
import {
|
import {
|
||||||
|
getAgentToolInput,
|
||||||
getAgentToolName,
|
getAgentToolName,
|
||||||
getAgentToolResult,
|
getAgentToolResult,
|
||||||
getAgentToolStatus,
|
getAgentToolStatus,
|
||||||
@@ -14,12 +16,16 @@
|
|||||||
type AgentToolPart
|
type AgentToolPart
|
||||||
} from '$features/ai-assistant/utils/tool-parts';
|
} from '$features/ai-assistant/utils/tool-parts';
|
||||||
import {
|
import {
|
||||||
isAiAssistantOpen,
|
chatDraft,
|
||||||
|
clearChatDraft,
|
||||||
initialChatModel,
|
initialChatModel,
|
||||||
initialChatPrompt,
|
initialChatPrompt,
|
||||||
|
initialChatTemplateId,
|
||||||
clearInitialChatModel,
|
clearInitialChatModel,
|
||||||
clearInitialChatPrompt
|
clearInitialChatPrompt,
|
||||||
|
clearInitialChatTemplateId
|
||||||
} from '$features/ai-assistant/utils/store';
|
} from '$features/ai-assistant/utils/store';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { Chat } from '@ai-sdk/svelte';
|
import { Chat } from '@ai-sdk/svelte';
|
||||||
@@ -28,23 +34,24 @@
|
|||||||
import { getMentionableContent } from '$features/course/utils/content';
|
import { getMentionableContent } from '$features/course/utils/content';
|
||||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||||
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
|
import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api';
|
||||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
|
||||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||||
import { isFreePlan } from '$lib/utils/store/org';
|
import { isFreePlan } from '$lib/utils/store/org';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import type {
|
import type {
|
||||||
AiAssistantMessage,
|
AiAssistantMessage,
|
||||||
AiAssistantMessageMetadata,
|
AiAssistantMessageMetadata,
|
||||||
|
AiAssistantTemplateMetadata,
|
||||||
UploadedDocument
|
UploadedDocument
|
||||||
} from '$features/ai-assistant/utils/types';
|
} from '$features/ai-assistant/utils/types';
|
||||||
|
import { getCourseTemplate, type CourseTemplateId, type TemplateFormField } from '@cio/ai-assistant';
|
||||||
import {
|
import {
|
||||||
AI_CHAT_MODEL_STORAGE_KEY,
|
AI_CHAT_MODEL_STORAGE_KEY,
|
||||||
AI_COURSE_CHAT_DEFAULT_WIDTH,
|
AI_ASSISTANT_QUICK_ACTION_ENTRIES,
|
||||||
AI_COURSE_CHAT_MAX_WIDTH,
|
STUDENT_QUICK_ACTION_ENTRIES
|
||||||
AI_COURSE_CHAT_MIN_WIDTH,
|
|
||||||
AI_COURSE_CHAT_STORAGE_KEY
|
|
||||||
} from '$features/ai-assistant/utils/constants';
|
} from '$features/ai-assistant/utils/constants';
|
||||||
|
import { calculateContextUsage } from '$features/ai-assistant/utils/context-utils';
|
||||||
import {
|
import {
|
||||||
AGENT_MODELS,
|
AGENT_MODELS,
|
||||||
AGENT_MODEL_IDS,
|
AGENT_MODEL_IDS,
|
||||||
@@ -53,14 +60,18 @@
|
|||||||
type AgentModelId
|
type AgentModelId
|
||||||
} from '@cio/utils/agent-models';
|
} from '@cio/utils/agent-models';
|
||||||
|
|
||||||
|
/** Every ~30% / 60% / 90% of tool steps completed, refetch course so UI reflects partial mutations */
|
||||||
|
const AGENT_STEP_PROGRESS_REFRESH_RATIOS = [0.3, 0.6, 0.9] as const;
|
||||||
|
|
||||||
|
/** Completed-step thresholds already refreshed for this streaming session */
|
||||||
|
let agentMutationProgressThresholdsTriggered = new SvelteSet<number>();
|
||||||
|
let lastSeenStreamingFlag = false;
|
||||||
|
|
||||||
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
|
const CONTINUE_IMPLEMENTATION_PROMPT = 'Continue implementing the plan from where you left off.';
|
||||||
|
|
||||||
interface Props {
|
// Read course id from the route. The chat panel is only mounted inside the
|
||||||
/** Course id from route / layout `data` (authoritative for this page). */
|
// course content layout, so `page.params.id` is always the active course.
|
||||||
courseId: string;
|
const courseId = $derived(page.params?.id as string);
|
||||||
}
|
|
||||||
|
|
||||||
let { courseId }: Props = $props();
|
|
||||||
|
|
||||||
// Extract current lessonId/exerciseId from route params
|
// Extract current lessonId/exerciseId from route params
|
||||||
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
const currentLessonId = $derived(page.params?.lessonId as string | undefined);
|
||||||
@@ -69,15 +80,13 @@
|
|||||||
let inputValue = $state('');
|
let inputValue = $state('');
|
||||||
let uploadedDocument: UploadedDocument | null = $state(null);
|
let uploadedDocument: UploadedDocument | null = $state(null);
|
||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
|
let contextFullBusy = $state<null | 'compact' | 'new_chat'>(null);
|
||||||
|
|
||||||
|
let pendingInitialTemplateId: CourseTemplateId | null = $state(null);
|
||||||
|
|
||||||
let statusFetchedForCourseId: string | null = $state(null);
|
let statusFetchedForCourseId: string | null = $state(null);
|
||||||
let conversationsLoadedForCourseId: string | null = $state(null);
|
let conversationsLoadedForCourseId: string | null = $state(null);
|
||||||
let activeConversationId: string | null = $state(null);
|
let activeConversationId: string | null = $state(null);
|
||||||
let chatWidth = $state(AI_COURSE_CHAT_DEFAULT_WIDTH);
|
|
||||||
let hasLoadedChatWidth = $state(false);
|
|
||||||
let isChatResizing = $state(false);
|
|
||||||
let stopChatResize: (() => void) | null = null;
|
|
||||||
let chatShellElement: HTMLDivElement | null = null;
|
|
||||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||||
|
|
||||||
@@ -120,15 +129,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function clampChatWidth(width: number) {
|
|
||||||
return Math.min(AI_COURSE_CHAT_MAX_WIDTH, Math.max(AI_COURSE_CHAT_MIN_WIDTH, width));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearChatResizeListeners() {
|
|
||||||
stopChatResize?.();
|
|
||||||
stopChatResize = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStorageKey(courseId: string) {
|
function getStorageKey(courseId: string) {
|
||||||
return `ai-chat-active-${courseId}`;
|
return `ai-chat-active-${courseId}`;
|
||||||
}
|
}
|
||||||
@@ -197,15 +197,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch status and load conversations when the panel opens or route `courseId` changes
|
async function handleRenameConversation(conversationId: string, title: string) {
|
||||||
$effect(() => {
|
const updatedTitle = await aiAssistantApi.renameConversation(conversationId, title);
|
||||||
if (!$isAiAssistantOpen) {
|
|
||||||
statusFetchedForCourseId = null;
|
|
||||||
conversationsLoadedForCourseId = null;
|
|
||||||
|
|
||||||
return;
|
if (!updatedTitle) {
|
||||||
|
const rawError = aiAssistantApi.error;
|
||||||
|
let message = t.get('ai_assistant.rename_chat_failed');
|
||||||
|
|
||||||
|
if (rawError && !rawError.startsWith('{')) {
|
||||||
|
message = rawError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch status and load conversations when the chat panel mounts (the
|
||||||
|
// SidePanelRail only mounts this component while the panel is active) or
|
||||||
|
// when the route's courseId changes.
|
||||||
|
$effect(() => {
|
||||||
if (!courseId) return;
|
if (!courseId) return;
|
||||||
|
|
||||||
if (statusFetchedForCourseId !== courseId) {
|
if (statusFetchedForCourseId !== courseId) {
|
||||||
@@ -216,14 +226,20 @@
|
|||||||
if (conversationsLoadedForCourseId !== courseId) {
|
if (conversationsLoadedForCourseId !== courseId) {
|
||||||
conversationsLoadedForCourseId = courseId;
|
conversationsLoadedForCourseId = courseId;
|
||||||
|
|
||||||
|
// If the panel was just opened to start a fresh chat (e.g. via quoteInChat),
|
||||||
|
// skip the auto-load so the draft effect can call startNewChat itself.
|
||||||
|
const pendingDraft = get(chatDraft);
|
||||||
|
|
||||||
|
if (pendingDraft?.mode === 'new') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const listForCourseId = courseId;
|
const listForCourseId = courseId;
|
||||||
const savedId = getActiveConversationId(listForCourseId);
|
const savedId = getActiveConversationId(listForCourseId);
|
||||||
|
|
||||||
aiAssistantApi.listConversations(listForCourseId).then(() => {
|
aiAssistantApi.listConversations(listForCourseId).then(() => {
|
||||||
if (courseId !== listForCourseId) return;
|
if (courseId !== listForCourseId) return;
|
||||||
|
|
||||||
// If the initial-prompt flow already created and claimed a conversation while
|
|
||||||
// listConversations was in-flight, don't clobber it by loading another one.
|
|
||||||
if (activeConversationId) return;
|
if (activeConversationId) return;
|
||||||
|
|
||||||
if (savedId) {
|
if (savedId) {
|
||||||
@@ -323,18 +339,53 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const quickActions = [
|
function buildTemplateAnswersSummary(
|
||||||
'Upload a document to create a course',
|
templateId: CourseTemplateId,
|
||||||
'Draft a lesson',
|
answers: Record<string, string>,
|
||||||
'Generate questions from this lesson',
|
fields: TemplateFormField[]
|
||||||
'Summarize this lesson',
|
): string {
|
||||||
'Help me publish this course'
|
const template = getCourseTemplate(templateId);
|
||||||
];
|
const registryById = new Map((template?.fields ?? []).map((field) => [field.id, field]));
|
||||||
|
const lines: string[] = ['Here are my answers for the course template:'];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field?.id || seen.has(field.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(field.id);
|
||||||
|
const trimmed = answers[field.id]?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = registryById.get(field.id)?.label ?? field.label ?? field.id;
|
||||||
|
lines.push(`- ${label}: ${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture any answers whose field isn't in the rendered list (defensive — shouldn't happen)
|
||||||
|
for (const [fieldId, value] of Object.entries(answers)) {
|
||||||
|
if (seen.has(fieldId)) continue;
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const label = registryById.get(fieldId)?.label ?? fieldId;
|
||||||
|
lines.push(`- ${label}: ${trimmed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
if (!inputValue.trim() || chat.status === 'streaming') return;
|
if (!inputValue.trim() || chat.status === 'streaming') return;
|
||||||
|
|
||||||
const text = inputValue;
|
const text = inputValue;
|
||||||
|
const userMessageCount = chat.messages.filter((message) => message.role === 'user').length;
|
||||||
|
const templateForFirstMessage = userMessageCount === 0 ? pendingInitialTemplateId : null;
|
||||||
|
|
||||||
const messageAttachment = uploadedDocument
|
const messageAttachment = uploadedDocument
|
||||||
? {
|
? {
|
||||||
documentId: uploadedDocument.id,
|
documentId: uploadedDocument.id,
|
||||||
@@ -342,6 +393,18 @@
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const metadata: AiAssistantMessageMetadata = {};
|
||||||
|
|
||||||
|
if (messageAttachment) {
|
||||||
|
metadata.attachment = messageAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateForFirstMessage) {
|
||||||
|
const templateMeta: AiAssistantTemplateMetadata = { id: templateForFirstMessage };
|
||||||
|
metadata.template = templateMeta;
|
||||||
|
pendingInitialTemplateId = null;
|
||||||
|
}
|
||||||
|
|
||||||
inputValue = '';
|
inputValue = '';
|
||||||
|
|
||||||
// Create conversation eagerly before sending the first message
|
// Create conversation eagerly before sending the first message
|
||||||
@@ -357,7 +420,48 @@
|
|||||||
|
|
||||||
chat.sendMessage({
|
chat.sendMessage({
|
||||||
text,
|
text,
|
||||||
metadata: messageAttachment ? { attachment: messageAttachment } : undefined
|
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitTemplateAnswers(payload: {
|
||||||
|
templateId: CourseTemplateId;
|
||||||
|
answers: Record<string, string>;
|
||||||
|
fields: TemplateFormField[];
|
||||||
|
}) {
|
||||||
|
if (!courseId || chat.status === 'streaming') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureActiveConversation(courseId);
|
||||||
|
|
||||||
|
chat.sendMessage({
|
||||||
|
text: buildTemplateAnswersSummary(payload.templateId, payload.answers, payload.fields),
|
||||||
|
metadata: {
|
||||||
|
template: {
|
||||||
|
action: 'submit_template_answers',
|
||||||
|
templateId: payload.templateId,
|
||||||
|
answers: payload.answers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSkipTemplateForm(payload: { templateId: CourseTemplateId }) {
|
||||||
|
if (!courseId || chat.status === 'streaming') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureActiveConversation(courseId);
|
||||||
|
|
||||||
|
chat.sendMessage({
|
||||||
|
text: "I'll answer your questions in chat instead of the form.",
|
||||||
|
metadata: {
|
||||||
|
template: {
|
||||||
|
action: 'skip_template_form',
|
||||||
|
templateId: payload.templateId
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,8 +532,20 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAskPlanChanges() {
|
function handleAskPlanChanges(message: string) {
|
||||||
inputValue = '';
|
const trimmed = message.trim();
|
||||||
|
|
||||||
|
if (!trimmed || chat.status === 'streaming') return;
|
||||||
|
|
||||||
|
if (!activeConversationId && courseId) {
|
||||||
|
aiAssistantApi.createConversation(courseId).then((created) => {
|
||||||
|
if (created) {
|
||||||
|
setActiveConversationId(courseId, created.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.sendMessage({ text: trimmed });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResume() {
|
function handleResume() {
|
||||||
@@ -441,9 +557,94 @@
|
|||||||
goto(resolve(route, {}));
|
goto(resolve(route, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStartNewChatWithSummary() {
|
||||||
|
if (!courseId || contextFullBusy) return;
|
||||||
|
|
||||||
|
contextFullBusy = 'new_chat';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await aiAssistantApi.summarizeConversation(chat.messages as AiAssistantMessage[], courseId);
|
||||||
|
|
||||||
|
chat.messages = [];
|
||||||
|
setActiveConversationId(courseId, null);
|
||||||
|
|
||||||
|
const created = await aiAssistantApi.createConversation(courseId);
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
setActiveConversationId(courseId, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
inputValue = summary;
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
contextFullBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompactConversation() {
|
||||||
|
if (!courseId || !activeConversationId || contextFullBusy) return;
|
||||||
|
|
||||||
|
contextFullBusy = 'compact';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const compacted = await aiAssistantApi.compactConversation(activeConversationId);
|
||||||
|
|
||||||
|
if (compacted && compacted.length > 0) {
|
||||||
|
chat.messages = compacted as AiAssistantMessage[];
|
||||||
|
await chat.regenerate();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
contextFullBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
const isStreaming = $derived(chat.status === 'streaming' || chat.status === 'submitted');
|
||||||
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
const isExhausted = $derived(tokenUsage !== null && tokenUsage.remaining <= 0);
|
||||||
|
|
||||||
|
const status = $derived(aiAssistantApi.status);
|
||||||
|
const isStudent = $derived(status?.role === 'student');
|
||||||
|
const tutorStatus = $derived(status?.tutor);
|
||||||
|
|
||||||
|
const tutorErrorCode = $derived.by(() => {
|
||||||
|
if (!chat.error) return null;
|
||||||
|
const message = chat.error.message ?? '';
|
||||||
|
if (message.includes('AI_TUTOR_DISABLED')) return 'AI_TUTOR_DISABLED' as const;
|
||||||
|
if (message.includes('LEARNER_CAP_REACHED')) return 'LEARNER_CAP_REACHED' as const;
|
||||||
|
if (message.includes('POOL_EXHAUSTED')) return 'POOL_EXHAUSTED' as const;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tutorBlocked = $derived.by(() => {
|
||||||
|
if (!isStudent) return null;
|
||||||
|
if (tutorErrorCode) return tutorErrorCode;
|
||||||
|
if (tutorStatus && tutorStatus.enabled === false) return 'AI_TUTOR_DISABLED' as const;
|
||||||
|
if (tutorStatus && tutorStatus.enforced && tutorStatus.capRemaining !== null && tutorStatus.capRemaining <= 0) {
|
||||||
|
return 'LEARNER_CAP_REACHED' as const;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quickActions = $derived(isStudent ? [...STUDENT_QUICK_ACTION_ENTRIES] : [...AI_ASSISTANT_QUICK_ACTION_ENTRIES]);
|
||||||
|
|
||||||
|
// Student-facing per-learner monthly cap (100 messages). Hidden when the agent reports
|
||||||
|
// no tutor data (e.g. provider unconfigured) so we don't render a 0/0 bar.
|
||||||
|
const studentMessageUsage = $derived.by(() => {
|
||||||
|
if (!isStudent || !tutorStatus || tutorStatus.cap == null || tutorStatus.capRemaining == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
used: Math.max(0, tutorStatus.cap - tutorStatus.capRemaining),
|
||||||
|
cap: tutorStatus.cap
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextUsage = $derived(
|
||||||
|
calculateContextUsage(chat.messages as AiAssistantMessage[], AGENT_MODELS[selectedModel].contextWindow)
|
||||||
|
);
|
||||||
|
|
||||||
const mentionItems = $derived(
|
const mentionItems = $derived(
|
||||||
getMentionableContent(courseApi.course).map((item) => ({
|
getMentionableContent(courseApi.course).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -455,9 +656,31 @@
|
|||||||
// Show an activity card whenever the agent calls any tool.
|
// Show an activity card whenever the agent calls any tool.
|
||||||
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
|
// Hides automatically once the agent finishes cleanly; stays visible if stopped mid-way.
|
||||||
const planExecutionState = $derived.by(() => {
|
const planExecutionState = $derived.by(() => {
|
||||||
const lastAssistantMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
|
const lastMsg = chat.messages[chat.messages.length - 1];
|
||||||
|
|
||||||
if (!lastAssistantMsg) return null;
|
if (!lastMsg) return null;
|
||||||
|
|
||||||
|
// If the most recent message is from the user, the agent has not produced a
|
||||||
|
// reply yet — don't show the previous assistant's tool card. While streaming
|
||||||
|
// is starting up, fall through to the thinking placeholder below.
|
||||||
|
if (lastMsg.role !== 'assistant') {
|
||||||
|
if (!isStreaming) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
status: 'in_progress' as const,
|
||||||
|
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentActionLine: undefined,
|
||||||
|
isStopped: false,
|
||||||
|
titleKey: 'ai_assistant.plan_working',
|
||||||
|
hasMutations: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastAssistantMsg = lastMsg;
|
||||||
|
|
||||||
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
|
const continuation = (lastAssistantMsg.metadata as AiAssistantMessageMetadata | undefined)?.continuation;
|
||||||
const reachedStepLimit = continuation?.reason === 'step_limit';
|
const reachedStepLimit = continuation?.reason === 'step_limit';
|
||||||
@@ -466,9 +689,29 @@
|
|||||||
) as AgentToolPart[];
|
) as AgentToolPart[];
|
||||||
|
|
||||||
// generate_course_plan renders its own PlanView — exclude it from the card
|
// generate_course_plan renders its own PlanView — exclude it from the card
|
||||||
const toolParts = allToolParts.filter((part) => getAgentToolName(part) !== 'generate_course_plan');
|
const toolParts = allToolParts.filter((part) => {
|
||||||
|
const toolName = getAgentToolName(part);
|
||||||
|
return toolName !== 'generate_course_plan' && toolName !== 'ask_template_questions';
|
||||||
|
});
|
||||||
|
|
||||||
if (toolParts.length === 0) return null;
|
if (toolParts.length === 0) {
|
||||||
|
if (!isStreaming) return null;
|
||||||
|
|
||||||
|
// The agent is mid-turn (reasoning / preparing a tool call) but hasn't emitted
|
||||||
|
// a real tool part yet. Show a placeholder step so the user always sees activity.
|
||||||
|
return {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
status: 'in_progress' as const,
|
||||||
|
line: { shape: 'i18n' as const, key: 'ai_assistant.plan_thinking' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentActionLine: undefined,
|
||||||
|
isStopped: false,
|
||||||
|
titleKey: 'ai_assistant.plan_working',
|
||||||
|
hasMutations: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
const steps: ProgressStep[] = toolParts.flatMap((part) => {
|
||||||
const toolName = getAgentToolName(part);
|
const toolName = getAgentToolName(part);
|
||||||
@@ -479,9 +722,12 @@
|
|||||||
|
|
||||||
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
const result = getAgentToolResult(part) as Record<string, unknown> | undefined;
|
||||||
const status = getAgentToolStatus(part);
|
const status = getAgentToolStatus(part);
|
||||||
const label = status === 'completed' ? getToolResultLabel(toolName, result) : getToolLabel(toolName);
|
const line =
|
||||||
|
status === 'completed'
|
||||||
|
? getCompletedToolLine(toolName, result)
|
||||||
|
: getPendingToolLine(toolName, getAgentToolInput(part));
|
||||||
|
|
||||||
return [{ label, status }];
|
return [{ line, status }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const allDone = steps.every((s) => s.status === 'completed');
|
const allDone = steps.every((s) => s.status === 'completed');
|
||||||
@@ -494,55 +740,60 @@
|
|||||||
const toolName = getAgentToolName(part);
|
const toolName = getAgentToolName(part);
|
||||||
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
|
return toolName ? MUTATION_TOOLS.includes(toolName) : false;
|
||||||
});
|
});
|
||||||
const title = hasMutations ? 'Applying changes' : 'Working';
|
const titleKey = hasMutations ? 'ai_assistant.plan_applying_changes' : 'ai_assistant.plan_working';
|
||||||
const currentAction = steps.find((s) => s.status === 'in_progress')?.label;
|
const currentActionLine = steps.find((s) => s.status === 'in_progress')?.line;
|
||||||
|
|
||||||
return { steps, currentAction, isStopped, title };
|
return { steps, currentActionLine, isStopped, titleKey, hasMutations };
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const streamingNow = isStreaming;
|
||||||
|
|
||||||
|
if (streamingNow && !lastSeenStreamingFlag) {
|
||||||
|
agentMutationProgressThresholdsTriggered.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeenStreamingFlag = streamingNow;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isStreaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = planExecutionState;
|
||||||
|
|
||||||
|
if (!state?.hasMutations || state.isStopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = state.steps.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedStepCount = state.steps.filter((s) => s.status === 'completed').length;
|
||||||
|
const stepThresholds = [
|
||||||
|
...new Set(
|
||||||
|
AGENT_STEP_PROGRESS_REFRESH_RATIOS.map((ratio) => Math.min(total, Math.max(1, Math.ceil(total * ratio))))
|
||||||
|
)
|
||||||
|
].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const threshold of stepThresholds) {
|
||||||
|
if (completedStepCount >= threshold && !agentMutationProgressThresholdsTriggered.has(threshold)) {
|
||||||
|
agentMutationProgressThresholdsTriggered.add(threshold);
|
||||||
|
|
||||||
|
refreshCourseStateAfterChat();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const conversationTitle = $derived(
|
const conversationTitle = $derived(
|
||||||
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
aiAssistantApi.conversations.find((c) => c.id === activeConversationId)?.title ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleChatResizePointerDown(event: PointerEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
clearChatResizeListeners();
|
|
||||||
|
|
||||||
const startWidth = chatWidth;
|
|
||||||
|
|
||||||
stopChatResize = startResizablePanelDrag({
|
|
||||||
event,
|
|
||||||
startWidth,
|
|
||||||
resolveWidth: ({ startWidth, deltaX }) => clampChatWidth(startWidth - deltaX),
|
|
||||||
onPreview: (width) => {
|
|
||||||
chatShellElement?.style.setProperty('--ai-chat-width', `${width}px`);
|
|
||||||
},
|
|
||||||
onCommit: ({ width }) => {
|
|
||||||
chatWidth = width;
|
|
||||||
},
|
|
||||||
onDragStart: () => {
|
|
||||||
isChatResizing = true;
|
|
||||||
},
|
|
||||||
onDragEnd: () => {
|
|
||||||
isChatResizing = false;
|
|
||||||
stopChatResize = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
try {
|
|
||||||
const storedWidth = Number(localStorage.getItem(AI_COURSE_CHAT_STORAGE_KEY));
|
|
||||||
|
|
||||||
if (Number.isFinite(storedWidth)) {
|
|
||||||
chatWidth = clampChatWidth(storedWidth);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||||
|
|
||||||
@@ -552,28 +803,22 @@
|
|||||||
} catch {
|
} catch {
|
||||||
// localStorage unavailable
|
// localStorage unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
hasLoadedChatWidth = true;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearChatResizeListeners();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
clearChatResizeListeners();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const prompt = $initialChatPrompt;
|
const prompt = $initialChatPrompt;
|
||||||
|
|
||||||
if (!prompt || !$isAiAssistantOpen || !courseId) return;
|
if (!prompt || !courseId) return;
|
||||||
|
|
||||||
const model = $initialChatModel;
|
const model = $initialChatModel;
|
||||||
|
const templateFromHome = $initialChatTemplateId;
|
||||||
clearInitialChatPrompt();
|
clearInitialChatPrompt();
|
||||||
clearInitialChatModel();
|
clearInitialChatModel();
|
||||||
|
clearInitialChatTemplateId();
|
||||||
|
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
|
pendingInitialTemplateId = templateFromHome ?? null;
|
||||||
|
|
||||||
if (model && isAgentModelId(model)) {
|
if (model && isAgentModelId(model)) {
|
||||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||||
openUpgradeModal();
|
openUpgradeModal();
|
||||||
@@ -589,21 +834,37 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!hasLoadedChatWidth) {
|
const draft = $chatDraft;
|
||||||
|
|
||||||
|
if (!draft || !draft.text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
clearChatDraft();
|
||||||
localStorage.setItem(AI_COURSE_CHAT_STORAGE_KEY, String(Math.round(chatWidth)));
|
|
||||||
} catch {
|
if (draft.mode === 'new') {
|
||||||
// localStorage unavailable
|
void startNewChat().then(() =>
|
||||||
|
tick().then(() => {
|
||||||
|
inputValue = `${draft.text}\n\n`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void tick().then(() => {
|
||||||
|
const existing = inputValue.trimEnd();
|
||||||
|
|
||||||
|
inputValue = existing ? `${draft.text}\n\n${existing}` : `${draft.text}\n\n`;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet chatPanelContent()}
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
{tokenUsage}
|
{tokenUsage}
|
||||||
|
{isStudent}
|
||||||
|
{studentMessageUsage}
|
||||||
{conversationTitle}
|
{conversationTitle}
|
||||||
conversations={aiAssistantApi.conversations}
|
conversations={aiAssistantApi.conversations}
|
||||||
{activeConversationId}
|
{activeConversationId}
|
||||||
@@ -611,18 +872,21 @@
|
|||||||
onNewChat={startNewChat}
|
onNewChat={startNewChat}
|
||||||
onLoadConversation={loadConversation}
|
onLoadConversation={loadConversation}
|
||||||
onDeleteConversation={handleDeleteConversation}
|
onDeleteConversation={handleDeleteConversation}
|
||||||
|
onRenameConversation={handleRenameConversation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatMessageList
|
<ChatMessageList
|
||||||
messages={chat.messages}
|
messages={chat.messages}
|
||||||
status={chat.status}
|
|
||||||
{isStreaming}
|
{isStreaming}
|
||||||
|
{isStudent}
|
||||||
{courseId}
|
{courseId}
|
||||||
{planExecutionState}
|
{planExecutionState}
|
||||||
{quickActions}
|
{quickActions}
|
||||||
onQuickAction={handleQuickAction}
|
onQuickAction={handleQuickAction}
|
||||||
onImplementPlan={handleImplementPlan}
|
onImplementPlan={handleImplementPlan}
|
||||||
onAskPlanChanges={handleAskPlanChanges}
|
onAskPlanChanges={handleAskPlanChanges}
|
||||||
|
onSubmitTemplateAnswers={handleSubmitTemplateAnswers}
|
||||||
|
onSkipTemplateForm={handleSkipTemplateForm}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
onResume={handleResume}
|
onResume={handleResume}
|
||||||
onMentionClick={handleMentionClick}
|
onMentionClick={handleMentionClick}
|
||||||
@@ -633,9 +897,15 @@
|
|||||||
{isStreaming}
|
{isStreaming}
|
||||||
{isExhausted}
|
{isExhausted}
|
||||||
{isUploading}
|
{isUploading}
|
||||||
|
{contextFullBusy}
|
||||||
|
compactConversationDisabled={!activeConversationId}
|
||||||
|
onCompactConversation={handleCompactConversation}
|
||||||
{uploadedDocument}
|
{uploadedDocument}
|
||||||
{mentionItems}
|
{mentionItems}
|
||||||
{selectedModel}
|
{selectedModel}
|
||||||
|
{contextUsage}
|
||||||
|
{isStudent}
|
||||||
|
{tutorBlocked}
|
||||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||||
error={chat.error}
|
error={chat.error}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
@@ -644,37 +914,6 @@
|
|||||||
onRemoveDocument={handleRemoveDocument}
|
onRemoveDocument={handleRemoveDocument}
|
||||||
onSelectModel={handleSelectModel}
|
onSelectModel={handleSelectModel}
|
||||||
onLockedModelSelect={handleLockedModelSelect}
|
onLockedModelSelect={handleLockedModelSelect}
|
||||||
|
onStartNewChatWithSummary={handleStartNewChatWithSummary}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
</div>
|
||||||
|
|
||||||
{#if $isAiAssistantOpen}
|
|
||||||
<div
|
|
||||||
bind:this={chatShellElement}
|
|
||||||
data-ai-chat-resizing={isChatResizing}
|
|
||||||
class="contents"
|
|
||||||
style={`--ai-chat-width: ${chatWidth}px;`}
|
|
||||||
>
|
|
||||||
<div class="hidden shrink-0 md:block md:w-(--ai-chat-width)"></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="ui:bg-background fixed inset-y-0 right-0 z-100 flex h-screen w-full flex-col border-l md:w-(--ai-chat-width)"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Resize AI assistant panel"
|
|
||||||
class="absolute inset-y-0 left-0 hidden w-4 -translate-x-1/2 cursor-col-resize border-0 bg-transparent md:flex"
|
|
||||||
onpointerdown={handleChatResizePointerDown}
|
|
||||||
>
|
|
||||||
<span class="ui:bg-border pointer-events-none h-full w-px"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{@render chatPanelContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global([data-ai-chat-resizing='true'] *) {
|
|
||||||
transition-property: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type {
|
|||||||
AgentConversationCreateData,
|
AgentConversationCreateData,
|
||||||
AgentConversationSummary,
|
AgentConversationSummary,
|
||||||
AgentStatusData,
|
AgentStatusData,
|
||||||
AiAssistantMessage
|
AiAssistantMessage,
|
||||||
|
CompactConversationRequest,
|
||||||
|
CompactConversationSuccess
|
||||||
} from '../utils/types';
|
} from '../utils/types';
|
||||||
|
|
||||||
class AiAssistantApi extends BaseApiWithErrors {
|
class AiAssistantApi extends BaseApiWithErrors {
|
||||||
@@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors {
|
|||||||
|
|
||||||
return generatedTitle;
|
return generatedTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameConversation(conversationId: string, title: string): Promise<string | null> {
|
||||||
|
let newTitle: string | null = null;
|
||||||
|
|
||||||
|
await this.execute<(typeof classroomio.agent.history)[':conversationId']['$patch']>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.agent.history[':conversationId'].$patch({
|
||||||
|
param: { conversationId },
|
||||||
|
json: { title }
|
||||||
|
}),
|
||||||
|
logContext: 'renaming conversation',
|
||||||
|
onSuccess: (result) => {
|
||||||
|
newTitle = (result.data as { id: string; title: string }).title;
|
||||||
|
|
||||||
|
this.conversations = this.conversations.map((c) => (c.id === conversationId ? { ...c, title: newTitle } : c));
|
||||||
|
|
||||||
|
if (this.currentConversation?.id === conversationId) {
|
||||||
|
this.currentConversation = { ...this.currentConversation, title: newTitle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async summarizeConversation(messages: AiAssistantMessage[], courseId: string): Promise<string | null> {
|
||||||
|
let summary: string | null = null;
|
||||||
|
|
||||||
|
await this.execute<(typeof classroomio.agent)['summarize']['$post']>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.agent.summarize.$post({
|
||||||
|
json: { messages, courseId }
|
||||||
|
}),
|
||||||
|
logContext: 'summarizing conversation',
|
||||||
|
onSuccess: (result) => {
|
||||||
|
summary = (result.data as { summary: string }).summary;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async compactConversation(conversationId: string): Promise<CompactConversationSuccess['data']['messages'] | null> {
|
||||||
|
let compacted: CompactConversationSuccess['data']['messages'] | null = null;
|
||||||
|
|
||||||
|
await this.execute<CompactConversationRequest>({
|
||||||
|
requestFn: () =>
|
||||||
|
classroomio.agent.history[':conversationId'].compact.$post({
|
||||||
|
param: { conversationId }
|
||||||
|
}),
|
||||||
|
logContext: 'compacting conversation',
|
||||||
|
onSuccess: (result) => {
|
||||||
|
compacted = result.data.messages;
|
||||||
|
|
||||||
|
if (this.currentConversation?.id === conversationId) {
|
||||||
|
this.currentConversation = {
|
||||||
|
...this.currentConversation,
|
||||||
|
messages: compacted as AiAssistantMessage[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return compacted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const aiAssistantApi = new AiAssistantApi();
|
export const aiAssistantApi = new AiAssistantApi();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Popover from '@cio/ui/base/popover';
|
import * as Popover from '@cio/ui/base/popover';
|
||||||
|
import { Input } from '@cio/ui/base/input';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
@@ -22,8 +24,15 @@
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StudentMessageUsage {
|
||||||
|
used: number;
|
||||||
|
cap: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tokenUsage: TokenUsage | null;
|
tokenUsage: TokenUsage | null;
|
||||||
|
isStudent: boolean;
|
||||||
|
studentMessageUsage: StudentMessageUsage | null;
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
activeConversationId: string | null;
|
activeConversationId: string | null;
|
||||||
conversationTitle: string | null;
|
conversationTitle: string | null;
|
||||||
@@ -31,25 +40,132 @@
|
|||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onLoadConversation: (id: string) => void;
|
onLoadConversation: (id: string) => void;
|
||||||
onDeleteConversation: (id: string) => void;
|
onDeleteConversation: (id: string) => void;
|
||||||
|
/** Resolves on success; rejects with Error when rename fails (message is user-presentable). */
|
||||||
|
onRenameConversation: (id: string, title: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tokenUsage,
|
tokenUsage,
|
||||||
|
isStudent,
|
||||||
|
studentMessageUsage,
|
||||||
conversations,
|
conversations,
|
||||||
activeConversationId,
|
activeConversationId,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
isNewChatDisabled,
|
isNewChatDisabled,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onLoadConversation,
|
onLoadConversation,
|
||||||
onDeleteConversation
|
onDeleteConversation,
|
||||||
|
onRenameConversation
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let historyPopoverOpen = $state(false);
|
let historyPopoverOpen = $state(false);
|
||||||
|
|
||||||
|
let editingConversationTitle = $state(false);
|
||||||
|
let draftConversationTitle = $state('');
|
||||||
|
let renameConversationError = $state<string | null>(null);
|
||||||
|
let titleInputRef: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
let renameCommitInFlight = $state(false);
|
||||||
|
let snapshotConversationIdForRename = $state<string | null>(null);
|
||||||
|
|
||||||
|
const canRenameConversation = $derived(
|
||||||
|
Boolean(activeConversationId && conversationTitle && conversationTitle !== 'New conversation')
|
||||||
|
);
|
||||||
|
|
||||||
function handleLoadConversation(id: string) {
|
function handleLoadConversation(id: string) {
|
||||||
onLoadConversation(id);
|
onLoadConversation(id);
|
||||||
historyPopoverOpen = false;
|
historyPopoverOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelRenameConversation() {
|
||||||
|
if (renameCommitInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingConversationTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingConversationTitle = false;
|
||||||
|
renameConversationError = null;
|
||||||
|
draftConversationTitle = conversationTitle ?? '';
|
||||||
|
snapshotConversationIdForRename = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
editingConversationTitle &&
|
||||||
|
snapshotConversationIdForRename != null &&
|
||||||
|
activeConversationId !== snapshotConversationIdForRename
|
||||||
|
) {
|
||||||
|
cancelRenameConversation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startRenameConversation() {
|
||||||
|
if (!canRenameConversation || !activeConversationId || !conversationTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameConversationError = null;
|
||||||
|
draftConversationTitle = conversationTitle;
|
||||||
|
snapshotConversationIdForRename = activeConversationId;
|
||||||
|
editingConversationTitle = true;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
titleInputRef?.focus();
|
||||||
|
titleInputRef?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRenameConversation() {
|
||||||
|
if (!activeConversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedTitle = draftConversationTitle.trim();
|
||||||
|
|
||||||
|
if (trimmedTitle.length === 0) {
|
||||||
|
cancelRenameConversation();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedTitle === conversationTitle) {
|
||||||
|
editingConversationTitle = false;
|
||||||
|
renameConversationError = null;
|
||||||
|
snapshotConversationIdForRename = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renameCommitInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onRenameConversation(activeConversationId, trimmedTitle);
|
||||||
|
editingConversationTitle = false;
|
||||||
|
renameConversationError = null;
|
||||||
|
snapshotConversationIdForRename = null;
|
||||||
|
} catch (renameError) {
|
||||||
|
const message = renameError instanceof Error ? renameError.message : t.get('ai_assistant.rename_chat_failed');
|
||||||
|
|
||||||
|
renameConversationError = message;
|
||||||
|
} finally {
|
||||||
|
renameCommitInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void commitRenameConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelRenameConversation();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border-b px-4 py-3">
|
<div class="border-b px-4 py-3">
|
||||||
@@ -59,7 +175,32 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
|
<h3 class="text-sm font-semibold">{$t('course.navItems.nav_ai_assistant')}</h3>
|
||||||
{#if conversationTitle && conversationTitle !== 'New conversation'}
|
{#if conversationTitle && conversationTitle !== 'New conversation'}
|
||||||
<p class="ui:text-muted-foreground truncate text-xs">{conversationTitle}</p>
|
{#if editingConversationTitle}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<Input
|
||||||
|
bind:ref={titleInputRef}
|
||||||
|
bind:value={draftConversationTitle}
|
||||||
|
class="ui:h-7 ui:min-h-0 ui:px-1.5 ui:py-0 text-xs"
|
||||||
|
placeholder={$t('ai_assistant.rename_chat_placeholder')}
|
||||||
|
aria-label={$t('ai_assistant.rename_chat_input_aria')}
|
||||||
|
onkeydown={handleTitleKeydown}
|
||||||
|
onblur={cancelRenameConversation}
|
||||||
|
/>
|
||||||
|
{#if renameConversationError}
|
||||||
|
<p class="ui:text-destructive mt-0.5 text-[10px]">{renameConversationError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui:text-muted-foreground ui:max-w-full ui:cursor-pointer ui:rounded-md ui:border ui:border-transparent ui:px-1.5 ui:py-0.5 hover:ui:bg-muted/40 hover:ui:border-border truncate text-left text-xs"
|
||||||
|
aria-label={$t('ai_assistant.rename_chat_aria')}
|
||||||
|
disabled={!canRenameConversation}
|
||||||
|
onclick={startRenameConversation}
|
||||||
|
>
|
||||||
|
{conversationTitle}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +244,28 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
|
{#if isStudent && studentMessageUsage}
|
||||||
|
{@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))}
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="ui:text-muted-foreground flex justify-between text-[10px]">
|
||||||
|
<span
|
||||||
|
>{studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()}
|
||||||
|
{$t('ai_assistant.messages_used_label')}</span
|
||||||
|
>
|
||||||
|
<span>{usagePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui:bg-muted mt-0.5 h-1 w-full rounded-full">
|
||||||
|
<div
|
||||||
|
class="h-1 rounded-full transition-all {usagePercent > 90
|
||||||
|
? 'bg-red-500'
|
||||||
|
: usagePercent > 70
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'ui:bg-primary'}"
|
||||||
|
style="width: {usagePercent}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !isStudent && tokenUsage && tokenUsage.used + tokenUsage.remaining > 0}
|
||||||
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
|
{@const totalBudget = tokenUsage.used + tokenUsage.remaining}
|
||||||
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
|
{@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
|
import { ChatTextarea, type MentionItem } from '@cio/ui/custom/chat-textarea';
|
||||||
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
|
||||||
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
|
||||||
@@ -14,6 +17,10 @@
|
|||||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||||
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
import { ModelPicker } from '@cio/ui/custom/model-picker';
|
||||||
import type { AgentModelId } from '@cio/utils/agent-models';
|
import type { AgentModelId } from '@cio/utils/agent-models';
|
||||||
|
import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants';
|
||||||
|
import ContextIndicator from './context-indicator.svelte';
|
||||||
|
import ContextFullState from './context-full-state.svelte';
|
||||||
|
import type { ContextUsage } from './utils/context-utils';
|
||||||
|
|
||||||
interface UploadedDocument {
|
interface UploadedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,17 +32,25 @@
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isExhausted: boolean;
|
isExhausted: boolean;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
|
contextFullBusy: null | 'compact' | 'new_chat';
|
||||||
|
compactConversationDisabled?: boolean;
|
||||||
error: Error | null | undefined;
|
error: Error | null | undefined;
|
||||||
mentionItems: MentionItem[];
|
mentionItems: MentionItem[];
|
||||||
uploadedDocument: UploadedDocument | null;
|
uploadedDocument: UploadedDocument | null;
|
||||||
selectedModel: AgentModelId;
|
selectedModel: AgentModelId;
|
||||||
lockedModelIds?: readonly AgentModelId[];
|
lockedModelIds?: readonly AgentModelId[];
|
||||||
|
contextUsage: ContextUsage | null;
|
||||||
|
isStudent?: boolean;
|
||||||
|
/** Set to 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' to render the take-a-break empty state. */
|
||||||
|
tutorBlocked?: 'LEARNER_CAP_REACHED' | 'POOL_EXHAUSTED' | 'AI_TUTOR_DISABLED' | null;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onFileSelect: (file: File) => void;
|
onFileSelect: (file: File) => void;
|
||||||
onRemoveDocument: () => void;
|
onRemoveDocument: () => void;
|
||||||
onSelectModel: (id: AgentModelId) => void;
|
onSelectModel: (id: AgentModelId) => void;
|
||||||
onLockedModelSelect?: (id: AgentModelId) => void;
|
onLockedModelSelect?: (id: AgentModelId) => void;
|
||||||
|
onStartNewChatWithSummary: () => void;
|
||||||
|
onCompactConversation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -43,23 +58,64 @@
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
isExhausted,
|
isExhausted,
|
||||||
isUploading,
|
isUploading,
|
||||||
|
contextFullBusy,
|
||||||
|
compactConversationDisabled = false,
|
||||||
error,
|
error,
|
||||||
mentionItems,
|
mentionItems,
|
||||||
uploadedDocument,
|
uploadedDocument,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
lockedModelIds = [],
|
lockedModelIds = [],
|
||||||
|
contextUsage,
|
||||||
|
isStudent = false,
|
||||||
|
tutorBlocked = null,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onRemoveDocument,
|
onRemoveDocument,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
onLockedModelSelect
|
onLockedModelSelect,
|
||||||
|
onStartNewChatWithSummary,
|
||||||
|
onCompactConversation
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function tutorBlockedMessage(reason: NonNullable<typeof tutorBlocked>): string {
|
||||||
|
if (reason === 'LEARNER_CAP_REACHED') return t.get('aiTutor.takeABreak.learnerCap');
|
||||||
|
if (reason === 'POOL_EXHAUSTED') return t.get('aiTutor.takeABreak.poolExhausted');
|
||||||
|
return t.get('aiTutor.takeABreak.disabled');
|
||||||
|
}
|
||||||
|
|
||||||
let fileInputEl: HTMLInputElement | undefined = $state();
|
let fileInputEl: HTMLInputElement | undefined = $state();
|
||||||
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
let chatTextareaRef: HTMLTextAreaElement | null = $state(null);
|
||||||
let wasBusy = $state(false);
|
let wasBusy = $state(false);
|
||||||
|
|
||||||
|
function readAgentRunningWarningDismissed() {
|
||||||
|
if (!browser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentRunningWarningDismissed = $state(readAgentRunningWarningDismissed());
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
agentRunningWarningDismissed = readAgentRunningWarningDismissed();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismissAgentRunningWarning() {
|
||||||
|
agentRunningWarningDismissed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AI_AGENT_RUNNING_WARNING_DISMISSED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const isBusy = isStreaming || isUploading;
|
const isBusy = isStreaming || isUploading;
|
||||||
|
|
||||||
@@ -102,6 +158,31 @@
|
|||||||
|
|
||||||
return t.get('ai_assistant.mention_lesson');
|
return t.get('ai_assistant.mention_lesson');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserFriendlyErrorMessage(errorMessage: string): string {
|
||||||
|
const lowerMessage = errorMessage.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerMessage.includes('quota exceeded') || lowerMessage.includes('rate limit')) {
|
||||||
|
const retryMatch = errorMessage.match(/retry in (\d+(?:\.\d+)?)/i);
|
||||||
|
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) : null;
|
||||||
|
|
||||||
|
return waitSeconds
|
||||||
|
? t.get('ai_assistant.error_rate_limit_with_wait', { seconds: waitSeconds })
|
||||||
|
: t.get('ai_assistant.error_rate_limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('context length') || lowerMessage.includes('too long')) {
|
||||||
|
return t.get('ai_assistant.error_context_too_long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('network') || lowerMessage.includes('connection')) {
|
||||||
|
return t.get('ai_assistant.error_network');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayErrorMessage = $derived(error ? getUserFriendlyErrorMessage(error.message) : null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -112,7 +193,22 @@
|
|||||||
onchange={handleFileChange}
|
onchange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="border-t px-3 pt-3 pb-1.5">
|
{#if tutorBlocked}
|
||||||
|
<div class="border-t px-3 py-4">
|
||||||
|
<div class="ui:bg-muted/40 ui:text-muted-foreground rounded-md border px-3 py-3 text-sm">
|
||||||
|
<p class="ui:text-foreground mb-1 text-sm font-medium">{$t('aiTutor.takeABreak.title')}</p>
|
||||||
|
<p class="text-xs">{tutorBlockedMessage(tutorBlocked)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if contextUsage?.isFull && !isStreaming}
|
||||||
|
<ContextFullState
|
||||||
|
{contextFullBusy}
|
||||||
|
{compactConversationDisabled}
|
||||||
|
{onCompactConversation}
|
||||||
|
onStartNewChat={onStartNewChatWithSummary}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="border-t px-3 pt-3 pb-1.5">
|
||||||
{#if isExhausted}
|
{#if isExhausted}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
class="flex flex-col gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||||
@@ -130,19 +226,29 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if isStreaming}
|
{#if isStreaming && !agentRunningWarningDismissed && !isStudent}
|
||||||
<div
|
<div
|
||||||
class="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
class="mb-2 flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
|
||||||
>
|
>
|
||||||
{$t('ai_assistant.agent_running_warning')}
|
<span class="min-w-0 flex-1">{$t('ai_assistant.agent_running_warning')}</span>
|
||||||
|
<IconButton
|
||||||
|
variant="outline"
|
||||||
|
size="icon-xs"
|
||||||
|
type="button"
|
||||||
|
tooltip={t.get('ai_assistant.agent_running_warning_dismiss')}
|
||||||
|
class="border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200 dark:hover:bg-amber-900"
|
||||||
|
onclick={dismissAgentRunningWarning}
|
||||||
|
>
|
||||||
|
<XIcon size={12} />
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if displayErrorMessage}
|
||||||
<div
|
<div
|
||||||
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
|
class="mb-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300"
|
||||||
>
|
>
|
||||||
{error.message}
|
{displayErrorMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -188,6 +294,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
{#if !isStudent}
|
||||||
<button
|
<button
|
||||||
onclick={handlePaperclipClick}
|
onclick={handlePaperclipClick}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
@@ -196,14 +303,19 @@
|
|||||||
>
|
>
|
||||||
<PaperclipIcon size={16} />
|
<PaperclipIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
{#if contextUsage && contextUsage.usedTokens > 0}
|
||||||
|
<ContextIndicator {contextUsage} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isStreaming}
|
{#if isStreaming}
|
||||||
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
<Button size="icon" variant="outline" onclick={onStop} class="size-7 shrink-0">
|
||||||
<SquareIcon size={12} />
|
<SquareIcon size={12} />
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else if !isStudent}
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
@@ -211,9 +323,14 @@
|
|||||||
onChange={onSelectModel}
|
onChange={onSelectModel}
|
||||||
onLockedSelect={onLockedModelSelect}
|
onLockedSelect={onLockedModelSelect}
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<Button size="sm" onclick={onSend} disabled={!inputValue.trim()} class="shrink-0">
|
||||||
|
{$t('ai_assistant.send')}
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChatTextarea>
|
</ChatTextarea>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -5,26 +5,40 @@
|
|||||||
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
import MessageBubble from '$features/ai-assistant/message-bubble.svelte';
|
||||||
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
import ProgressCard from '$features/ai-assistant/progress-card.svelte';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels';
|
import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels';
|
||||||
import type { AiAssistantMessage } from '$features/ai-assistant/utils/types';
|
import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types';
|
||||||
|
import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant';
|
||||||
|
|
||||||
|
interface QuickActionOption {
|
||||||
|
key: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PlanExecutionState {
|
interface PlanExecutionState {
|
||||||
title: string;
|
titleKey: string;
|
||||||
steps: ProgressStep[];
|
steps: ProgressStep[];
|
||||||
currentAction?: string;
|
currentActionLine?: ToolLineUi;
|
||||||
isStopped: boolean;
|
isStopped: boolean;
|
||||||
|
/** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */
|
||||||
|
hasMutations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: AiAssistantMessage[];
|
messages: AiAssistantMessage[];
|
||||||
status: string;
|
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isStudent: boolean;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
planExecutionState: PlanExecutionState | null;
|
planExecutionState: PlanExecutionState | null;
|
||||||
quickActions: string[];
|
quickActions: QuickActionOption[];
|
||||||
onQuickAction: (action: string) => void;
|
onQuickAction: (action: string) => void;
|
||||||
onImplementPlan: (editedPlan: unknown) => void;
|
onImplementPlan: (editedPlan: unknown) => void;
|
||||||
onAskPlanChanges: () => void;
|
onAskPlanChanges: (message: string) => void;
|
||||||
|
onSubmitTemplateAnswers: (payload: {
|
||||||
|
templateId: CourseTemplateId;
|
||||||
|
answers: Record<string, string>;
|
||||||
|
fields: TemplateFormField[];
|
||||||
|
}) => void;
|
||||||
|
onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
onMentionClick: (route: string) => void;
|
onMentionClick: (route: string) => void;
|
||||||
@@ -32,14 +46,16 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
messages,
|
messages,
|
||||||
status,
|
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isStudent,
|
||||||
courseId,
|
courseId,
|
||||||
planExecutionState,
|
planExecutionState,
|
||||||
quickActions,
|
quickActions,
|
||||||
onQuickAction,
|
onQuickAction,
|
||||||
onImplementPlan,
|
onImplementPlan,
|
||||||
onAskPlanChanges,
|
onAskPlanChanges,
|
||||||
|
onSubmitTemplateAnswers,
|
||||||
|
onSkipTemplateForm,
|
||||||
onStop,
|
onStop,
|
||||||
onResume,
|
onResume,
|
||||||
onMentionClick
|
onMentionClick
|
||||||
@@ -48,13 +64,34 @@
|
|||||||
let messagesContainer: HTMLDivElement | undefined = $state();
|
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||||
let lastMessageCount = $state(0);
|
let lastMessageCount = $state(0);
|
||||||
let lastStepsCount = $state(0);
|
let lastStepsCount = $state(0);
|
||||||
let lastCurrentAction = $state('');
|
let lastCurrentActionSig = $state('');
|
||||||
|
let lastStreamingSig = $state(0);
|
||||||
|
|
||||||
const isEmpty = $derived(messages.length === 0);
|
const isEmpty = $derived(messages.length === 0);
|
||||||
|
|
||||||
function scrollToBottom() {
|
// Total text length of the most recent assistant message — grows as tokens stream in.
|
||||||
|
// Used to keep the view pinned to the latest text while the AI is typing.
|
||||||
|
const streamingContentSig = $derived.by(() => {
|
||||||
|
if (messages.length === 0) return 0;
|
||||||
|
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
|
||||||
|
if (last.role !== 'assistant') return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const part of last.parts ?? []) {
|
||||||
|
if ((part as { type?: string }).type === 'text') {
|
||||||
|
total += ((part as { text?: string }).text ?? '').length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
});
|
||||||
|
|
||||||
|
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +104,16 @@
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!messagesContainer) return;
|
||||||
|
|
||||||
|
if (streamingContentSig === lastStreamingSig) return;
|
||||||
|
|
||||||
|
lastStreamingSig = streamingContentSig;
|
||||||
|
// Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor.
|
||||||
|
scrollToBottom('auto');
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!messagesContainer || !planExecutionState) return;
|
if (!messagesContainer || !planExecutionState) return;
|
||||||
|
|
||||||
@@ -77,9 +124,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = planExecutionState.currentAction ?? '';
|
const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : '';
|
||||||
if (action !== lastCurrentAction) {
|
|
||||||
lastCurrentAction = action;
|
if (actionSig !== lastCurrentActionSig) {
|
||||||
|
lastCurrentActionSig = actionSig;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -92,17 +140,17 @@
|
|||||||
<div class="flex flex-col items-center gap-2 text-center">
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
<SparklesIcon size={32} class="ui:text-muted-foreground" />
|
<SparklesIcon size={32} class="ui:text-muted-foreground" />
|
||||||
<p class="ui:text-muted-foreground text-sm">
|
<p class="ui:text-muted-foreground text-sm">
|
||||||
{$t('ai_assistant.empty_state')}
|
{$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-2">
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
{#each quickActions as action (action)}
|
{#each quickActions as option (option.key)}
|
||||||
<button
|
<button
|
||||||
onclick={() => onQuickAction(action)}
|
onclick={() => onQuickAction(option.prompt)}
|
||||||
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
class="ui:text-muted-foreground hover:ui:bg-muted cursor-pointer rounded-full border px-3 py-1.5 text-xs transition-colors"
|
||||||
>
|
>
|
||||||
{action}
|
{$t(option.key)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -110,11 +158,31 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Message list -->
|
<!-- Message list -->
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each messages as message (message.id)}
|
{#each messages as message, messageIndex (message.id)}
|
||||||
<MessageBubble {message} {courseId} {onImplementPlan} {onAskPlanChanges} {onMentionClick} />
|
{@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction}
|
||||||
|
{@const isLast = messageIndex === messages.length - 1}
|
||||||
|
|
||||||
|
{#if compaction}
|
||||||
|
<p class="ui:text-muted-foreground mx-1 px-2 text-[11px] leading-snug">
|
||||||
|
{$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<MessageBubble
|
||||||
|
{message}
|
||||||
|
{messages}
|
||||||
|
{courseId}
|
||||||
|
{isStreaming}
|
||||||
|
{isLast}
|
||||||
|
{onImplementPlan}
|
||||||
|
{onAskPlanChanges}
|
||||||
|
{onSubmitTemplateAnswers}
|
||||||
|
{onSkipTemplateForm}
|
||||||
|
{onMentionClick}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if status === 'submitted' && isStreaming}
|
{#if isStreaming && (isStudent || !planExecutionState)}
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<div class="ui:bg-muted rounded-lg px-3 py-2">
|
<div class="ui:bg-muted rounded-lg px-3 py-2">
|
||||||
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
<LoaderIcon size={16} class="ui:text-muted-foreground animate-spin" />
|
||||||
@@ -122,12 +190,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if planExecutionState}
|
{#if planExecutionState && !isStudent}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<ProgressCard
|
<ProgressCard
|
||||||
title={planExecutionState.title}
|
titleKey={planExecutionState.titleKey}
|
||||||
steps={planExecutionState.steps}
|
steps={planExecutionState.steps}
|
||||||
currentAction={planExecutionState.currentAction}
|
currentActionLine={planExecutionState.currentActionLine}
|
||||||
|
{courseId}
|
||||||
|
onNavigate={onMentionClick}
|
||||||
onStop={isStreaming ? onStop : undefined}
|
onStop={isStreaming ? onStop : undefined}
|
||||||
isStopped={planExecutionState.isStopped}
|
isStopped={planExecutionState.isStopped}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import MessageSquarePlusIcon from '@lucide/svelte/icons/message-square-plus';
|
||||||
|
import Minimize2Icon from '@lucide/svelte/icons/minimize-2';
|
||||||
|
import LoaderIcon from '@lucide/svelte/icons/loader';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contextFullBusy: null | 'compact' | 'new_chat';
|
||||||
|
compactConversationDisabled?: boolean;
|
||||||
|
onCompactConversation: () => void;
|
||||||
|
onStartNewChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contextFullBusy, compactConversationDisabled = false, onCompactConversation, onStartNewChat }: Props = $props();
|
||||||
|
|
||||||
|
const compactBusy = $derived(contextFullBusy === 'compact');
|
||||||
|
const busy = $derived(contextFullBusy !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border-t px-3 py-4">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-5 text-center dark:border-amber-800 dark:bg-amber-950"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{$t('ai_assistant.context_full_title')}
|
||||||
|
</p>
|
||||||
|
<p class="ui:text-muted-foreground text-xs">
|
||||||
|
{$t('ai_assistant.context_full_description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex w-full max-w-xs flex-col gap-2 pt-1 sm:max-w-sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onclick={onCompactConversation}
|
||||||
|
disabled={busy || compactConversationDisabled}
|
||||||
|
class="w-full gap-2"
|
||||||
|
>
|
||||||
|
{#if compactBusy}
|
||||||
|
<LoaderIcon size={14} class="shrink-0 animate-spin" />
|
||||||
|
{$t('ai_assistant.context_full_compacting')}
|
||||||
|
{:else}
|
||||||
|
<Minimize2Icon size={14} class="shrink-0" />
|
||||||
|
{$t('ai_assistant.context_full_compact')}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={onStartNewChat}
|
||||||
|
disabled={busy}
|
||||||
|
class="w-full gap-2 border-amber-300 bg-amber-50/70 text-amber-900 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100 dark:hover:bg-amber-900"
|
||||||
|
>
|
||||||
|
{#if contextFullBusy === 'new_chat'}
|
||||||
|
<LoaderIcon size={14} class="shrink-0 animate-spin" />
|
||||||
|
{$t('ai_assistant.context_full_summarizing')}
|
||||||
|
{:else}
|
||||||
|
<MessageSquarePlusIcon size={14} class="shrink-0" />
|
||||||
|
{$t('ai_assistant.context_full_new_chat')}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user