diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..90308884 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 00000000..8bf7cc27 --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 00000000..6595b051 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "svelte": { + "enabled": true + } + } +} diff --git a/.gitignore b/.gitignore index 22bcfeb9..25a5adb0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,14 @@ vite.config.ts*.mjs tsconfig.tsbuildinfo* +# Stray TS build artifacts emitted next to sources (e.g. from `tsc ` without project) +apps/api/src/**/*.js +apps/api/src/**/*.js.map +apps/api/src/**/*.d.ts +!apps/api/src/types/css-tree.d.ts + storybook-static -.svelte-kit/ \ No newline at end of file +.svelte-kit/ + +company/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..0a904865 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +reporter=append-only diff --git a/AGENTS.md b/AGENTS.md index 317f4359..929180b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ This document collects implementation rules and workflow conventions for code changes. +## Research Requirements + +When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway. + ## Naming Convention - Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`). @@ -77,6 +81,40 @@ import { accountRouter } from '@api/routes/account'; export const app = new Hono().route('/account', accountRouter); ``` +**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`. + +```typescript +// apps/api/src/routes/internal/index.ts +import { Hono } from '@api/utils/hono'; +import { internalAnalyticsRouter } from './analytics'; +import { internalComplianceRouter } from './compliance'; + +export const internalRouter = new Hono() + .route('/compliance', internalComplianceRouter) + .route('/analytics', internalAnalyticsRouter); + +// apps/api/src/app.ts +import { internalRouter } from '@api/routes/internal'; +export const app = new Hono().route('/internal', internalRouter); +``` + +**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`. + +```typescript +// apps/api/src/routes/internal/index.ts +import { Hono } from '@api/utils/hono'; +import { internalAnalyticsRouter } from './analytics'; +import { internalComplianceRouter } from './compliance'; + +export const internalRouter = new Hono() + .route('/compliance', internalComplianceRouter) + .route('/analytics', internalAnalyticsRouter); + +// apps/api/src/app.ts +import { internalRouter } from '@api/routes/internal'; +export const app = new Hono().route('/internal', internalRouter); +``` + ### Step 6: Build ```bash pnpm --filter @cio/api build diff --git a/apps/api/.env.example b/apps/api/.env.example index a7ffb982..53714f50 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -9,12 +9,20 @@ BETTER_AUTH_SECRET="" AUTH_BEARER_TOKEN="" AUTH_COOKIE_DOMAIN="" +# Email provider — used by the API only for auth-flow emails (forgot +# password, password-reset confirmation, email verification, email-change +# verification). Every other email is sent by @cio/jobs-worker; see +# apps/jobs/.env.example. Both processes can point at the same SMTP server. SMTP_HOST="" SMTP_PORT="" SMTP_USER="" SMTP_SENDER="" SMTP_PASSWORD="" +# Optional Zoho ZeptoMail token. When set, @cio/email uses Zoho instead of +# nodemailer/SMTP. Same dual-process note as SMTP above. +# ZOHO_TOKEN="" + # Optional - MinIO (S3-compatible, for Docker / on-premise self-hosting) # When API runs in Docker: use http://minio:9000. For local API: use http://localhost:9000 MINIO_ROOT_USER="" @@ -34,6 +42,19 @@ CLOUDFLARE_BUCKET_DOMAIN="" CLOUDFLARE_SECRET_ACCESS_KEY="" SENTRY_DNS="" +# BullMQ dashboard (/admin/queues) ─────────────────────────────────────────── +# In dev the dashboard is open. In production it's hidden unless ONE of the +# following is set; failing requests get 404 (not 401) so the route stays +# invisible to scanners. +# +# Comma-separated allowlist of Better Auth user emails — log in normally, +# visit /admin/queues, the middleware checks your session. +# QUEUE_DASHBOARD_ADMIN_EMAILS="you@yourdomain.com,partner@yourdomain.com" +# +# Bearer-token escape hatch for curl / scripts. Pass via +# `Authorization: Bearer ` or `?token=`. +# QUEUE_DASHBOARD_TOKEN="" + # Enterprise (Optional) # Required only when using Enterprise-only features (SSO, token-auth, no-tracking) LICENSE_KEY="" @@ -51,6 +72,11 @@ GOOGLE_API_KEY="" ANTHROPIC_API_KEY="" MOONSHOT_API_KEY="" +# Jina Reader — optional Bearer for higher rate limits when using fetch_documentation_url +# JINA_API_KEY="" +# AGENT_MAX_FETCHES_PER_CONVERSATION=15 +# AGENT_MAX_FETCHES_PER_ORG_PER_DAY=500 + # Tinybird event tracking for AI observability (optional — silently skipped when not set) # TINYBIRD_TOKEN="" # TINYBIRD_BASE_URL="https://api.tinybird.co" \ No newline at end of file diff --git a/apps/api/.prettierignore b/apps/api/.prettierignore index 7b4d4ba2..c36ad9df 100644 --- a/apps/api/.prettierignore +++ b/apps/api/.prettierignore @@ -1 +1,2 @@ static +dist diff --git a/apps/api/package.json b/apps/api/package.json index b519e75d..d4481759 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "build:clean": "rm -rf dist tsconfig.tsbuildinfo && pnpm run build", "dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"", "dev:server": "tsx watch src/index.ts", - "dev:tsc": "tsc -p tsconfig.json --watch", + "dev:tsc": "tsc -p tsconfig.json --watch --preserveWatchOutput", "deploy": "wrangler deploy", "start": "node dist/index.js", "test": "vitest NODE_ENV=test", @@ -40,9 +40,14 @@ "dependencies": { "@aws-sdk/client-s3": "^3.388.0", "@aws-sdk/s3-request-presigner": "^3.675.0", + "@bull-board/api": "^7.0.0", + "@bull-board/hono": "^7.0.0", "@cio/ai-assistant": "workspace:*", + "@cio/analytics": "workspace:*", + "@cio/certificates": "workspace:*", "@cio/db": "workspace:*", "@cio/email": "workspace:*", + "@cio/jobs": "workspace:*", "@cio/question-types": "workspace:*", "@cio/utils": "workspace:*", "@hono/node-server": "^1.19.7", @@ -76,7 +81,7 @@ "@typescript-eslint/parser": "7.0.1", "@vitest/coverage-v8": "^1.2.2", "eslint": "^9.31.0", - "prettier": "^3.2.5", + "prettier": "3.6.2", "ts-node": "^10.9.2", "tsc-alias": "^1.8.16", "tsx": "^4.7.1", diff --git a/apps/api/scripts/upload-openapi-spec.js b/apps/api/scripts/upload-openapi-spec.js new file mode 100644 index 00000000..a1876b27 --- /dev/null +++ b/apps/api/scripts/upload-openapi-spec.js @@ -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); + }); +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 969ff526..3bf84d0e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,6 +14,7 @@ import { inviteRouter } from '@api/routes/invite'; import { logger } from 'hono/logger'; // ROUTES import { mailRouter } from '@api/routes/mail'; +import { jobsRouter } from '@api/routes/jobs'; import { mediaRouter } from '@api/routes/media'; import { onboardingRouter } from '@api/routes/onboarding'; import { organizationRouter } from '@api/routes/organization'; @@ -31,7 +32,8 @@ import { signupGuard } from '@api/middlewares/signup-guard'; import { programRouter } from '@api/routes/program'; import { unsplashRouter } from '@api/routes/unsplash/unsplash'; import { agentRouter } from '@api/routes/agent'; -import { internalComplianceRouter } from '@api/routes/internal/compliance'; +import { internalRouter } from '@api/routes/internal'; +import { mountQueueDashboard } from '@api/routes/admin/queues'; // Create Hono app with chaining for RPC support export const app = new Hono() @@ -43,7 +45,7 @@ export const app = new Hono() '*', cors({ origin: TRUSTED_ORIGINS, - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'], exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], maxAge: 600, @@ -94,6 +96,7 @@ export const app = new Hono() .route('/domain', domainRouter) .route('/mail', mailRouter) .route('/media', mediaRouter) + .route('/jobs', jobsRouter) .route('/license', licenseRouter) .route('/organization', organizationRouter) .route('/organization/sso', organizationSsoRouter) @@ -107,7 +110,7 @@ export const app = new Hono() .route('/program', programRouter) .route('/unsplash', unsplashRouter) .route('/widgets', publicWidgetsRouter) - .route('/internal/compliance', internalComplianceRouter) + .route('/internal', internalRouter) .route('/agent', agentRouter) // Error handling @@ -115,3 +118,7 @@ export const app = new Hono() console.error('Error:', err); return c.json({ error: 'Internal Server Error' }, 500); }); + +// Dev-only BullMQ dashboard at /admin/queues. Mounted after the typed RPC +// chain so it doesn't pollute the client type. +mountQueueDashboard(app); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 551311dc..ccbc8984 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -48,7 +48,47 @@ const envSchema = z.object({ /** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */ LICENSE_KEY: z.string().optional(), /** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */ - DASHBOARD_ORIGIN: z.string().optional() + DASHBOARD_ORIGIN: z.string().optional(), + /** + * Comma-separated list of Better Auth user emails allowed to access the + * BullMQ dashboard at /admin/queues in production. Leave unset to keep the + * dashboard hidden everywhere except dev. + */ + QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(), + /** + * Bearer-token escape hatch for /admin/queues — useful for curl scripts or + * when SSO is unavailable. When set, requests with `Authorization: Bearer + * ` (or `?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); diff --git a/apps/api/src/constants/upload.ts b/apps/api/src/constants/upload.ts index 806a88fe..c9e2d23a 100644 --- a/apps/api/src/constants/upload.ts +++ b/apps/api/src/constants/upload.ts @@ -1,5 +1,5 @@ // API-specific constants (file size limits) -export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB +export const MAX_FILE_SIZE = 800 * 1024 * 1024; // 800MB export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB diff --git a/apps/api/src/middlewares/workspace-not-readonly.ts b/apps/api/src/middlewares/workspace-not-readonly.ts new file mode 100644 index 00000000..1d75015c --- /dev/null +++ b/apps/api/src/middlewares/workspace-not-readonly.ts @@ -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(); +}; diff --git a/apps/api/src/routes/account/account.ts b/apps/api/src/routes/account/account.ts index 0bdaf50a..5eb06156 100644 --- a/apps/api/src/routes/account/account.ts +++ b/apps/api/src/routes/account/account.ts @@ -2,12 +2,14 @@ import { getAccountData, updateUser } from '@api/services/account'; import { Hono } from '@api/utils/hono'; import { ZUpdateProfile } from '@cio/utils/validation/account'; +import { accountWorkspacesRouter } from '@api/routes/account/workspaces'; import { authMiddleware } from '@api/middlewares/auth'; import { getProfileById } from '@cio/db/queries/auth'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; export const accountRouter = new Hono() + .route('/', accountWorkspacesRouter) .get('/', authMiddleware, async (c) => { const user = c.get('user')!; diff --git a/apps/api/src/routes/account/workspaces.ts b/apps/api/src/routes/account/workspaces.ts new file mode 100644 index 00000000..ad82c09c --- /dev/null +++ b/apps/api/src/routes/account/workspaces.ts @@ -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'); + } + }); diff --git a/apps/api/src/routes/admin/queues.ts b/apps/api/src/routes/admin/queues.ts new file mode 100644 index 00000000..45771f62 --- /dev/null +++ b/apps/api/src/routes/admin/queues.ts @@ -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): 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 { + 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 { + 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; +} diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index ca25a4c1..db6b481a 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -11,7 +11,12 @@ import { ZAgentCreditPurchase, ZAgentCreditsBody, ZAgentGenerateCourseTitleBody, - ZAgentStatusQuery + ZAgentGenerateTextBody, + ZAgentStatusQuery, + ZAgentSummarizeBody, + ZTutorUsageQuery, + ZTutorUsagePeriod, + ZTutorUsageUserParam } from '@cio/utils/validation/agent'; import { addCredits, @@ -23,15 +28,32 @@ import { isOrgOnPaidPlan, recordTokenUsage } from '@api/services/agent/usage'; +import { + enforceStudentTutorPolicy, + getStudentTutorStatus, + getTutorCapStatusSummary, + getTutorLearnerDetail, + getTutorLearnerLeaderboard, + incrementStudentTutorCount +} from '@api/services/agent/tutor-usage'; +import { buildStudentAgentTools } from '@api/services/agent/student-tools'; import { parseAndStoreDocument } from '@api/services/agent/document'; import { recordCreditPurchase } from '@api/services/agent/credit-purchase'; import { generateCourseMeta } from '@api/services/agent/title-generation'; +import { generateFieldText } from '@api/services/agent/text-generation'; import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group'; import { getChatConversation } from '@cio/db/queries/agent'; -import { AgentRole, AIProvider, MAX_STEPS_PER_ROUND, type AgentContext, type AgentStatus } from '@cio/ai-assistant'; +import { + AgentRole, + AIProvider, + MAX_STEPS_PER_ROUND, + getCourseTemplate, + type AgentContext, + type AgentStatus +} from '@cio/ai-assistant'; import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers'; import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models'; -import { buildSystemPrompt } from '@cio/ai-assistant/prompt'; +import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt'; import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird'; import { redis } from '@api/utils/redis/redis'; import { db } from '@cio/db'; @@ -41,14 +63,17 @@ import { listCourseSections } from '@api/services/course/section'; import { getLesson } from '@api/services/lesson/lesson'; import { getExercise } from '@api/services/exercise/exercise'; import { trimMessageHistory } from '@api/services/agent/context-window'; +import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls'; import { collectDocumentIds, + getActiveCourseTemplateId, getLatestImplementationPlan, loadDocumentsText, verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from '@api/services/agent/chat-context'; import { buildAgentTools } from '@api/services/agent/chat-tools'; +import { summarizeConversation } from '@api/services/agent/summarize'; import { agentHistoryRouter } from './history'; const agentCoreRouter = new Hono() @@ -63,7 +88,8 @@ const agentCoreRouter = new Hono() const status: AgentStatus = { enabled: false, role: AgentRole.STUDENT, - usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 } + usage: { used: 0, allowance: 0, creditBalance: 0, remaining: 0 }, + tutor: { enabled: false, capRemaining: null, cap: null, enforced: false } }; return c.json({ success: true, data: status }); @@ -74,10 +100,16 @@ const agentCoreRouter = new Hono() const usage = await getTokenBalance(orgId); + const tutor = + role === AgentRole.STUDENT + ? await getStudentTutorStatus(orgId, courseId, user.id) + : { enabled: true, cap: null, capRemaining: null, enforced: false }; + const status: AgentStatus = { enabled: true, role, - usage + usage, + tutor }; return c.json({ success: true, data: status }); @@ -187,6 +219,67 @@ const agentCoreRouter = new Hono() return handleError(c, error, 'Failed to fetch usage stats'); } }) + .get( + '/tutor-usage/leaderboard', + authMiddleware, + orgMemberMiddleware, + zValidator('query', ZTutorUsageQuery), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const { period, search, sort, page, limit } = c.req.valid('query'); + const data = await getTutorLearnerLeaderboard(orgId, { period, search, sort, page, limit }); + + return c.json({ success: true as const, data }); + } catch (error) { + return handleError(c, error, 'Failed to fetch learner leaderboard'); + } + } + ) + .get( + '/tutor-usage/summary', + authMiddleware, + orgMemberMiddleware, + zValidator( + 'query', + ZTutorUsagePeriod.optional() + .transform((v) => v ?? 'current') + .pipe(ZTutorUsagePeriod) + .or(ZTutorUsagePeriod) + .transform((v) => ({ period: v })) + ), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const period = c.req.query('period'); + const parsed = ZTutorUsagePeriod.safeParse(period); + const data = await getTutorCapStatusSummary(orgId, parsed.success ? parsed.data : 'current'); + + return c.json({ success: true as const, data }); + } catch (error) { + return handleError(c, error, 'Failed to fetch tutor cap summary'); + } + } + ) + .get( + '/tutor-usage/:userId', + authMiddleware, + orgAdminMiddleware, + zValidator('param', ZTutorUsageUserParam), + async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const { userId } = c.req.valid('param'); + const period = c.req.query('period'); + const parsed = ZTutorUsagePeriod.safeParse(period); + const data = await getTutorLearnerDetail(orgId, userId, parsed.success ? parsed.data : 'current'); + + return c.json({ success: true as const, data }); + } catch (error) { + return handleError(c, error, 'Failed to fetch learner detail'); + } + } + ) .post('/credits', authMiddleware, orgAdminMiddleware, zValidator('json', ZAgentCreditsBody), async (c) => { try { const orgId = c.req.header('cio-org-id')!; @@ -233,6 +326,59 @@ const agentCoreRouter = new Hono() } } ) + .post( + '/generate-text', + authMiddleware, + orgMemberMiddleware, + zValidator('json', ZAgentGenerateTextBody), + async (c) => { + try { + const user = c.get('user')!; + const orgId = c.req.header('cio-org-id')!; + const { prompt, tone, format, context, courseId } = c.req.valid('json'); + + const providerConfig = pickAnyConfiguredProvider(); + + if (!providerConfig) { + throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503); + } + + await enforceTokenBalance(orgId); + + const { text, usage, modelName } = await generateFieldText(prompt, tone, format, context, providerConfig); + + if (courseId) { + await recordTokenUsage(orgId, user.id, courseId, usage, modelName); + } + + return c.json({ success: true as const, data: { text } }); + } catch (error) { + return handleError(c, error, 'Failed to generate text'); + } + } + ) + .post('/summarize', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentSummarizeBody), async (c) => { + try { + const { messages, courseId } = c.req.valid('json'); + const user = c.get('user')!; + + const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id); + + if (!isTeamMember) { + throw new AppError( + 'You must be a course team member to summarize conversations', + 'NOT_COURSE_TEAM_MEMBER', + 403 + ); + } + + const summary = await summarizeConversation({ messages }); + + return c.json({ success: true as const, data: { summary } }); + } catch (error) { + return handleError(c, error, 'Failed to summarize conversation'); + } + }) .post('/chat', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentChatBody), async (c) => { const user = c.get('user')!; const orgId = c.req.header('cio-org-id')!; @@ -240,7 +386,14 @@ const agentCoreRouter = new Hono() try { const { courseId, messages, context, model: requestedModel } = c.req.valid('json'); - const modelId = requestedModel ?? DEFAULT_PICKER_MODEL_ID; + const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id); + const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT; + + // Students always use the platform default; admins can pick. Forcing the + // default avoids exposing premium models to learners and keeps per-message + // cost predictable for fair-use accounting. + const modelId = + role === AgentRole.STUDENT ? DEFAULT_PICKER_MODEL_ID : (requestedModel ?? DEFAULT_PICKER_MODEL_ID); const modelDescriptor = AGENT_MODELS[modelId]; const baseProviderConfig = getProviderConfigForProvider(modelDescriptor.provider as AIProvider); @@ -250,13 +403,6 @@ const agentCoreRouter = new Hono() const providerConfig = { ...baseProviderConfig, model: modelDescriptor.backendModelId }; - const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id); - const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT; - - if (role === AgentRole.STUDENT) { - throw new AppError('AI assistant is not available for students yet', 'STUDENT_NOT_SUPPORTED', 403); - } - const [courseRow] = await db .select({ title: schema.course.title, @@ -276,7 +422,7 @@ const agentCoreRouter = new Hono() throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403); } - if (!modelDescriptor.isFree) { + if (role === AgentRole.TEACHER && !modelDescriptor.isFree) { const isPaid = await isOrgOnPaidPlan(orgId); if (!isPaid) { @@ -284,7 +430,14 @@ const agentCoreRouter = new Hono() } } - await enforceTokenBalance(orgId); + // Students go through tutor policy (workspace toggle, pool, per-learner cap). + // Teachers continue to use the existing pool-only check. + const studentPolicy = + role === AgentRole.STUDENT ? await enforceStudentTutorPolicy(orgId, courseId, user.id) : null; + + if (role === AgentRole.TEACHER) { + await enforceTokenBalance(orgId); + } const documentIds = collectDocumentIds(messages, context?.documentId); const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined; @@ -348,38 +501,98 @@ const agentCoreRouter = new Hono() const startTime = Date.now(); const model = createModel(providerConfig); - const approvedPlan = getLatestImplementationPlan(messages); - const systemPrompt = [ - buildSystemPrompt(agentContext), + const approvedPlan = role === AgentRole.TEACHER ? getLatestImplementationPlan(messages) : undefined; + const activeTemplateId = role === AgentRole.TEACHER ? getActiveCourseTemplateId(messages) : undefined; + const activeTemplate = activeTemplateId ? getCourseTemplate(activeTemplateId) : undefined; + + // Stable across requests — safe to cache as a long-lived Anthropic prefix. + // Volatile per-request context (lesson/exercise/document/section count/ + // approved plan/active template) is sent as a user-turn message instead so + // it doesn't invalidate the tools+system cache. + const systemPrompt = buildSystemPrompt(agentContext, { + tutorSettings: studentPolicy?.settings + }); + + const contextMessageText = buildContextMessage(agentContext, { + template: activeTemplate, approvedPlan - ? [ - '', - 'The latest user message approved a final course plan for immediate execution.', - 'Implement that exact plan directly without asking the user to restate it.', - 'Treat this approved plan as the canonical source if it differs from any earlier draft.', - `Approved plan JSON:\n${JSON.stringify(approvedPlan, null, 2)}` - ].join('\n') - : '' - ].join('\n'); - const agentTools = buildAgentTools(orgId, user.id, courseId); + }); + + const agentTools = + role === AgentRole.STUDENT + ? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings) + : buildAgentTools(orgId, user.id, courseId, messages); const trimmedMessages = trimMessageHistory(messages); - const modelMessages = await convertToModelMessages(trimmedMessages); + const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages)); let completedStepCount = 0; let finishReason: string | undefined; const isAnthropic = providerConfig.provider === AIProvider.ANTHROPIC; + + // 1h TTL keeps the prefix warm across tool-execution gaps in long agent + // runs. Break-even is 3 requests within the hour; well under most plan- + // execution loops. const systemContent = isAnthropic ? { role: 'system' as const, content: systemPrompt, - providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } } + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } + } } : systemPrompt; + // Prepend volatile context as a user-turn message so the stable system + + // tools prefix stays cacheable even when the teacher navigates to a + // different lesson or the agent creates sections mid-run. + const modelMessages = + contextMessageText.length > 0 + ? [ + { role: 'user' as const, content: [{ type: 'text' as const, text: contextMessageText }] }, + ...convertedMessages + ] + : convertedMessages; + + console.log( + '[agent.chat] modelMessages →', + JSON.stringify( + modelMessages.map((m, i) => ({ + i, + role: m.role, + parts: Array.isArray(m.content) + ? m.content.map((p: any) => ({ + type: p.type, + toolCallId: p.toolCallId, + toolName: p.toolName, + textPreview: typeof p.text === 'string' ? p.text.slice(0, 60) : undefined + })) + : typeof m.content === 'string' + ? m.content.slice(0, 80) + : m.content + })), + null, + 2 + ) + ); + + // Cache the growing conversation prefix: each turn reads the prior + // transcript at ~0.1x cost instead of reprocessing it at full price. + if (isAnthropic && modelMessages.length > 0) { + const lastMessage = modelMessages[modelMessages.length - 1]; + const existingAnthropic = (lastMessage.providerOptions?.anthropic as Record | undefined) ?? {}; + lastMessage.providerOptions = { + ...(lastMessage.providerOptions ?? {}), + anthropic: { + ...existingAnthropic, + cacheControl: { type: 'ephemeral', ttl: '1h' } + } + }; + } + const result = streamText({ model, - maxRetries: 0, + maxRetries: 2, system: systemContent, messages: modelMessages, tools: agentTools, @@ -394,6 +607,21 @@ const agentCoreRouter = new Hono() const inputTokens = totalUsage?.inputTokens ?? 0; const outputTokens = totalUsage?.outputTokens ?? 0; + // Cache hit/miss visibility. If cacheRead stays 0 across repeated + // turns of the same conversation, a silent invalidator is leaking + // into the cached prefix — audit system prompt and tool definitions. + if (isAnthropic) { + const details = totalUsage?.inputTokenDetails; + const cacheRead = details?.cacheReadTokens ?? 0; + const cacheWrite = details?.cacheWriteTokens ?? 0; + const uncached = details?.noCacheTokens ?? inputTokens; + const total = uncached + cacheRead + cacheWrite; + const hitRate = total > 0 ? Math.round((cacheRead / total) * 100) : 0; + console.log( + `[agent.chat] cache hit=${hitRate}% read=${cacheRead} write=${cacheWrite} uncached=${uncached} output=${outputTokens}` + ); + } + if (totalUsage) { await recordTokenUsage( orgId, @@ -408,6 +636,10 @@ const agentCoreRouter = new Hono() ); } + if (role === AgentRole.STUDENT) { + await incrementStudentTutorCount(orgId, user.id, courseId); + } + trackAgentEvent(AgentEvent.CHAT_COMPLETED, { orgId, userId: user.id, diff --git a/apps/api/src/routes/agent/history.ts b/apps/api/src/routes/agent/history.ts index 110ef5c4..6c91878f 100644 --- a/apps/api/src/routes/agent/history.ts +++ b/apps/api/src/routes/agent/history.ts @@ -7,6 +7,7 @@ import { ZAgentConversationParam, ZAgentConversationCreateBody, ZAgentHistorySaveBody, + ZAgentHistoryRenameBody, ZAgentHistoryDeleteParam, ZAgentGenerateTitleParam, ZAgentGenerateTitleBody @@ -20,6 +21,7 @@ import { deleteChatConversation, updateConversationTitle } from '@api/services/agent/chat-history'; +import { compactConversation } from '@api/services/agent/compact'; import { generateConversationTitle } from '@api/services/agent/title-generation'; import { pickAnyConfiguredProvider } from '@cio/ai-assistant/providers'; @@ -110,6 +112,37 @@ export const agentHistoryRouter = new Hono() } ) + /** + * PATCH /agent/history/:conversationId + * Rename a conversation without re-uploading messages. + */ + .patch( + '/:conversationId', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZAgentConversationParam), + zValidator('json', ZAgentHistoryRenameBody), + async (c) => { + try { + const user = c.get('user')!; + const { conversationId } = c.req.valid('param'); + const { title } = c.req.valid('json'); + + const conversation = await getChatConversation(conversationId, user.id); + + if (!conversation) { + return c.json({ success: false as const, error: 'Conversation not found' }, 404); + } + + await updateConversationTitle(conversationId, user.id, title); + + return c.json({ success: true as const, data: { id: conversationId, title } }); + } catch (error) { + return handleError(c, error, 'Failed to rename conversation'); + } + } + ) + /** * DELETE /agent/history/:conversationId * Delete a conversation. @@ -133,6 +166,28 @@ export const agentHistoryRouter = new Hono() } ) + /** + * POST /agent/history/:conversationId/compact + * Summarize and replace persisted messages with a single seed message. + */ + .post( + '/:conversationId/compact', + authMiddleware, + orgMemberMiddleware, + zValidator('param', ZAgentConversationParam), + async (c) => { + try { + const user = c.get('user')!; + const { conversationId } = c.req.valid('param'); + const data = await compactConversation(conversationId, user.id); + + return c.json({ success: true as const, data }); + } catch (error) { + return handleError(c, error, 'Failed to compact conversation'); + } + } + ) + /** * POST /agent/history/:conversationId/generate-title * Generate a short title from the first user message using a cheap model. diff --git a/apps/api/src/routes/course/ai-tutor.ts b/apps/api/src/routes/course/ai-tutor.ts new file mode 100644 index 00000000..cc19b861 --- /dev/null +++ b/apps/api/src/routes/course/ai-tutor.ts @@ -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'); + } + }); diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index 6f718832..b03eee35 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -1,5 +1,5 @@ import { - ZCertificateDownload, + ZCertificateDownloadRequest, ZCourseClone, ZCourseCloneParam, ZCourseCreate, @@ -31,6 +31,7 @@ import { getCourseTags, replaceCourseTags } from '@api/services/tag'; import { Hono } from '@api/utils/hono'; import { attendanceRouter } from '@api/routes/course/attendance'; +import { courseAiTutorRouter } from '@api/routes/course/ai-tutor'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { cloneCourse } from '@api/services/course/clone'; @@ -43,9 +44,11 @@ import { createRateLimiter } from '@api/middlewares/rate-limiter'; import { enrollInCourse } from '@api/services/course/invite'; import { exerciseRouter } from '@api/routes/course/exercise'; import { extractClientIp } from '@api/utils/redis/key-generators'; -import { generateCertificate } from '@api/utils/certificate'; +import { generateCertificatePdf, generateCertificatePng } from '@api/utils/certificate'; +import { assembleCertificateRender, assembleOwnerPreviewRender } from '@api/services/course/certificate'; +import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group'; import { generateCoursePdf } from '@api/utils/course'; -import { handleError } from '@api/utils/errors'; +import { AppError, ErrorCodes, handleError } from '@api/utils/errors'; import { invitesRouter } from '@api/routes/course/invite'; import { katexRouter } from '@api/routes/course/katex'; import { lessonRouter } from '@api/routes/course/lesson'; @@ -62,6 +65,36 @@ import { submissionRouter } from '@api/routes/course/submission'; import { updateCourseLandingPageService } from '@api/services/course/landing-page'; import { zValidator } from '@hono/zod-validator'; +function slugifyForFilename(value: string): string { + return ( + value + .normalize('NFKD') + .replace(/[^a-zA-Z0-9 ]/g, '') + .trim() + .replace(/\s+/g, '-') + .slice(0, 60) || 'certificate' + ); +} + +async function loadCertificateInput( + courseId: string, + userId: string, + body: import('@cio/utils/validation/course').TCertificateDownloadRequest +) { + if (body.previewMode) { + const isTeam = await isCourseTeamMemberOrOrgAdmin(courseId, userId); + if (!isTeam) { + throw new AppError('Only course team members can preview certificate designs', ErrorCodes.UNAUTHORIZED, 403); + } + + return assembleOwnerPreviewRender(courseId, userId, body); + } + + await assertCertificateDownloadAllowed(courseId, userId); + + return assembleCertificateRender(courseId, body); +} + const enrollRateLimit = createRateLimiter({ windowMs: 60 * 60 * 1000, maxRequests: 20, @@ -454,27 +487,67 @@ export const courseRouter = new Hono() authMiddleware, courseMemberMiddleware, zValidator('param', ZCourseDownloadParam), - zValidator('json', ZCertificateDownload), + zValidator('json', ZCertificateDownloadRequest), async (c) => { - const { courseId } = c.req.valid('param'); - const user = c.get('user')!; - await assertCertificateDownloadAllowed(courseId, user.id); + try { + const { courseId } = c.req.valid('param'); + const user = c.get('user')!; + 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-Disposition', + `attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.pdf"` + ); - c.header('Content-Type', 'application/pdf'); - c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`); + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(buffer); + controller.close(); + } + }) + ); + } catch (error) { + return handleError(c, error, 'Failed to download certificate'); + } + } + ) + .post( + '/:courseId/download/certificate/png', + authMiddleware, + courseMemberMiddleware, + zValidator('param', ZCourseDownloadParam), + zValidator('json', ZCertificateDownloadRequest), + async (c) => { + try { + const { courseId } = c.req.valid('param'); + const user = c.get('user')!; + const body = c.req.valid('json'); - return c.body( - new ReadableStream({ - start(controller) { - controller.enqueue(result); - controller.close(); - } - }) - ); + const input = await loadCertificateInput(courseId, user.id, body); + const buffer = await generateCertificatePng(input); + + c.header('Content-Type', 'image/png'); + c.header( + 'Content-Disposition', + `attachment; filename="certificate-${slugifyForFilename(input.data.courseName)}.png"` + ); + + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(buffer); + controller.close(); + } + }) + ); + } catch (error) { + return handleError(c, error, 'Failed to download certificate image'); + } } ) .post( @@ -542,4 +615,5 @@ export const courseRouter = new Hono() .route('/:courseId/newsfeed', newsfeedRouter) .route('/:courseId/members', membersRouter) .route('/:courseId/invites', invitesRouter) + .route('/:courseId/ai-tutor', courseAiTutorRouter) .route('/presign', presignRouter); diff --git a/apps/api/src/routes/dash/stats.ts b/apps/api/src/routes/dash/stats.ts index 9c677d0f..0bdfc1ac 100644 --- a/apps/api/src/routes/dash/stats.ts +++ b/apps/api/src/routes/dash/stats.ts @@ -1,13 +1,44 @@ import { Hono } from '@api/utils/hono'; -import { ZDashStats, ZLoginActivity } from '@cio/utils/validation/dash'; +import { + ZDashAnalyticsRange, + ZDashComplianceOverview, + ZDashCourseFunnel, + ZDashStats, + ZIngestBatch, + ZLoginActivity +} from '@cio/utils/validation/dash'; import { authMiddleware } from '@api/middlewares/auth'; import { getCurrentUserLoginStreak, getOrganisationAnalytics, getStudentLoginActivity } from '@api/services/dash'; +import { getOrgComplianceOverview } from '@api/services/course/compliance'; import { handleError } from '@api/utils/errors'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { zValidator } from '@hono/zod-validator'; +import { + getCountryBreakdown, + getCourseFunnel, + getLandingStats, + getPopularTypes, + ingestEventBatch +} from '@api/services/analytics'; export const dashAnalyticsRouter = new Hono() + .post('/track', zValidator('json', ZIngestBatch), async (c) => { + try { + const batch = c.req.valid('json'); + const user = c.get('user'); + + const result = await ingestEventBatch(batch, { + country: c.req.header('cf-ipcountry') ?? c.req.header('CF-IPCountry') ?? null, + userAgent: c.req.header('user-agent') ?? null, + userId: user?.id ?? null + }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to ingest analytics events'); + } + }) .get('/stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashStats), async (c) => { try { const { orgId, siteName } = c.req.valid('query'); @@ -39,4 +70,70 @@ export const dashAnalyticsRouter = new Hono() } catch (error) { return handleError(c, error, 'Failed to load login streak'); } - }); + }) + .get('/landing-stats', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => { + try { + const { orgId, days } = c.req.valid('query'); + const bust = c.req.query('bust') === '1'; + const result = await getLandingStats(orgId, days, bust); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load landing stats'); + } + }) + .get( + '/country-breakdown', + authMiddleware, + orgMemberMiddleware, + zValidator('query', ZDashAnalyticsRange), + async (c) => { + try { + const { orgId, days } = c.req.valid('query'); + const bust = c.req.query('bust') === '1'; + const result = await getCountryBreakdown(orgId, days, bust); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load country breakdown'); + } + } + ) + .get('/course-funnel', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashCourseFunnel), async (c) => { + try { + const { orgId, days, courseId } = c.req.valid('query'); + const bust = c.req.query('bust') === '1'; + const result = await getCourseFunnel(orgId, days, courseId, bust); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load course funnel'); + } + }) + .get('/popular-types', authMiddleware, orgMemberMiddleware, zValidator('query', ZDashAnalyticsRange), async (c) => { + try { + const { orgId, days } = c.req.valid('query'); + const bust = c.req.query('bust') === '1'; + const result = await getPopularTypes(orgId, days, bust); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load popular course types'); + } + }) + .get( + '/compliance-overview', + authMiddleware, + orgAdminMiddleware, + zValidator('query', ZDashComplianceOverview), + async (c) => { + try { + const { orgId } = c.req.valid('query'); + const result = await getOrgComplianceOverview(orgId); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load compliance overview'); + } + } + ); diff --git a/apps/api/src/routes/internal/account.ts b/apps/api/src/routes/internal/account.ts new file mode 100644 index 00000000..ec3b6822 --- /dev/null +++ b/apps/api/src/routes/internal/account.ts @@ -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'); + } +}); diff --git a/apps/api/src/routes/internal/analytics.ts b/apps/api/src/routes/internal/analytics.ts new file mode 100644 index 00000000..f4716fd4 --- /dev/null +++ b/apps/api/src/routes/internal/analytics.ts @@ -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'); + } +}); diff --git a/apps/api/src/routes/internal/compliance.ts b/apps/api/src/routes/internal/compliance.ts index f7fadee4..86774517 100644 --- a/apps/api/src/routes/internal/compliance.ts +++ b/apps/api/src/routes/internal/compliance.ts @@ -1,4 +1,5 @@ import { runComplianceExpiryCheck, runComplianceReminderScan } from '@api/services/course/compliance'; +import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/services/program/goal'; import { Hono } from '@api/utils/hono'; import { apiKeyMiddleware } from '@api/middlewares/api-key'; @@ -34,4 +35,32 @@ export const internalComplianceRouter = new Hono() } catch (error) { return handleError(c, error, 'Failed to run compliance reminder scan'); } + }) + /** + * POST /internal/compliance/evaluate-program-goals + * Daily sweep: re-evaluate every active program goal and refresh per-learner + * `program_goal_assignment` rows. + */ + .post('/evaluate-program-goals', apiKeyMiddleware, async (c) => { + try { + const data = await runProgramGoalEvaluationSweep(); + + return c.json({ success: true, data }, 200); + } catch (error) { + return handleError(c, error, 'Failed to run program goal evaluation sweep'); + } + }) + /** + * POST /internal/compliance/send-program-goal-reminders + * Sends learner reminder emails for upcoming/overdue program goals using + * each goal's `reminderDaysBefore` cadence. + */ + .post('/send-program-goal-reminders', apiKeyMiddleware, async (c) => { + try { + const data = await runProgramGoalReminderScan(); + + return c.json({ success: true, data }, 200); + } catch (error) { + return handleError(c, error, 'Failed to run program goal reminder scan'); + } }); diff --git a/apps/api/src/routes/internal/index.ts b/apps/api/src/routes/internal/index.ts new file mode 100644 index 00000000..9176bcfb --- /dev/null +++ b/apps/api/src/routes/internal/index.ts @@ -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); diff --git a/apps/api/src/routes/jobs/index.ts b/apps/api/src/routes/jobs/index.ts new file mode 100644 index 00000000..62e42d90 --- /dev/null +++ b/apps/api/src/routes/jobs/index.ts @@ -0,0 +1 @@ +export * from './jobs'; diff --git a/apps/api/src/routes/jobs/jobs.ts b/apps/api/src/routes/jobs/jobs.ts new file mode 100644 index 00000000..8a103975 --- /dev/null +++ b/apps/api/src/routes/jobs/jobs.ts @@ -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'); + } + } + ); diff --git a/apps/api/src/routes/mail/mail.ts b/apps/api/src/routes/mail/mail.ts index e9100f7a..adcf5fa6 100644 --- a/apps/api/src/routes/mail/mail.ts +++ b/apps/api/src/routes/mail/mail.ts @@ -1,31 +1,45 @@ import { Hono } from '@api/utils/hono'; import { ZDeliverEmail } from '@cio/utils/validation/mail'; import { apiKeyMiddleware } from '@api/middlewares/api-key'; -import { deliverEmail } from '@cio/email'; +import { enqueueRawEmail } from '@api/services/jobs'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; +/** + * Public mail endpoint — accepts a list of raw subject/content emails and + * enqueues them on the BullMQ `emails` queue via the transactional outbox. + * Returns 202 with the outbox + delivery ids so callers can poll status if + * needed. + */ export const mailRouter = new Hono().post('/', apiKeyMiddleware, zValidator('json', ZDeliverEmail), async (c) => { const validatedData = c.req.valid('json'); try { - const results = await deliverEmail(validatedData); + const enqueued = await Promise.all( + validatedData.map((emailItem) => + enqueueRawEmail({ + from: emailItem.from, + to: emailItem.to, + subject: emailItem.subject, + content: emailItem.content, + replyTo: emailItem.replyTo + }) + ) + ); - const hasErrors = results.some((result) => !result.success); - if (hasErrors) { - return c.json( - { - success: false, - error: 'Some emails failed to send', - code: 'EMAIL_SEND_PARTIAL_FAILURE', - details: results - }, - 500 - ); - } + const jobIds = enqueued.flatMap((entry) => entry.jobIds); - return c.json({ success: true, data: results }, 200); + return c.json( + { + success: true, + data: { + accepted: jobIds.length, + jobIds + } + }, + 202 + ); } catch (error) { - return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED'); + return handleError(c, error, 'Failed to enqueue email send request', 'EMAIL_SEND_FAILED'); } }); diff --git a/apps/api/src/routes/organization/ai-tutor.ts b/apps/api/src/routes/organization/ai-tutor.ts new file mode 100644 index 00000000..c531a1a8 --- /dev/null +++ b/apps/api/src/routes/organization/ai-tutor.ts @@ -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'); + } + }); diff --git a/apps/api/src/routes/organization/assets.ts b/apps/api/src/routes/organization/assets.ts index 03da7d62..375f094a 100644 --- a/apps/api/src/routes/organization/assets.ts +++ b/apps/api/src/routes/organization/assets.ts @@ -19,6 +19,7 @@ import { getAssetService, getAssetUsageGraphService, getOrganizationAssetStorageService, + getTranscriptForOrganizationAssetService, getYouTubeMetadataService, listOrganizationAssetsService, updateAssetService @@ -150,6 +151,27 @@ export const assetsRouter = new Hono() } } ) + /** + * GET /organization/assets/:assetId/transcript + * Whisper transcript + presigned VTT URL for captions (or null if none). + */ + .get('/:assetId/transcript', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => { + try { + const orgId = c.req.header('cio-org-id')!; + const { assetId } = c.req.valid('param'); + const data = await getTranscriptForOrganizationAssetService(orgId, assetId); + + return c.json( + { + success: true, + data + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch transcript'); + } + }) /** * GET /organization/assets/:assetId * Get a single asset by id diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 9b85de76..789088b1 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono'; import { ROLE } from '@cio/utils/constants'; import { TOrganization } from '@db/types'; import { assetsRouter } from '@api/routes/organization/assets'; +import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; @@ -670,4 +671,5 @@ export const organizationRouter = new Hono() .route('/tags', tagsRouter) .route('/widgets', widgetsRouter) .route('/assets', assetsRouter) + .route('/ai-tutor', organizationAiTutorRouter) .route('/:orgId/quiz', quizRouter); diff --git a/apps/api/src/routes/organization/widgets.ts b/apps/api/src/routes/organization/widgets.ts index 50b4299f..96944ffd 100644 --- a/apps/api/src/routes/organization/widgets.ts +++ b/apps/api/src/routes/organization/widgets.ts @@ -3,19 +3,12 @@ import { authMiddleware } from '@api/middlewares/auth'; import { handleError } from '@api/utils/errors'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; -import { - ZCreateWidget, - ZPreviewWidget, - ZRollbackWidget, - ZUpdateWidget, - ZWidgetIdParams -} from '@cio/utils/validation/widget'; +import { ZCreateWidget, ZRollbackWidget, ZUpdateWidget, ZWidgetIdParams } from '@cio/utils/validation/widget'; import { createOrganizationWidget, deleteOrganizationWidget, getOrganizationWidgetDetail, listOrganizationWidgets, - previewOrganizationWidget, publishOrganizationWidget, rollbackOrganizationWidget, updateOrganizationWidget @@ -87,25 +80,6 @@ export const widgetsRouter = new Hono() return handleError(c, error, 'Failed to delete widget'); } }) - .post( - '/:widgetId/preview', - authMiddleware, - orgTeamMemberMiddleware, - zValidator('param', ZWidgetIdParams), - zValidator('json', ZPreviewWidget), - async (c) => { - try { - const orgId = c.req.header('cio-org-id')!; - const { widgetId } = c.req.valid('param'); - const data = c.req.valid('json'); - const payload = await previewOrganizationWidget(orgId, widgetId, data); - - return c.json({ success: true, data: payload }); - } catch (error) { - return handleError(c, error, 'Failed to build widget preview'); - } - } - ) .post('/:widgetId/publish', authMiddleware, orgAdminMiddleware, zValidator('param', ZWidgetIdParams), async (c) => { try { const orgId = c.req.header('cio-org-id')!; diff --git a/apps/api/src/routes/program/program.ts b/apps/api/src/routes/program/program.ts index e8ffc288..f3eaa062 100644 --- a/apps/api/src/routes/program/program.ts +++ b/apps/api/src/routes/program/program.ts @@ -3,10 +3,14 @@ import * as z from 'zod'; import { ZAddCourseToProgram, ZAddProgramMembers, + ZAssignExistingStudentsToProgram, ZCreateProgram, + ZCreateProgramGoal, ZCreateProgramNewsfeed, ZCreateProgramNewsfeedComment, + ZInviteStudentsToProgram, ZUpdateProgram, + ZUpdateProgramGoal, ZUpdateProgramMember, ZUpdateProgramNewsfeed, ZUpdateProgramReaction @@ -34,9 +38,23 @@ import { updateProgramNewsfeedReactionService, updateProgramNewsfeedService } from '@api/services/program/program'; +import { assignExistingStudentsToProgram, inviteStudentsToProgram } from '@api/services/program/invite'; +import { + archiveGoal, + createGoal, + evaluateGoal, + evaluateProgramGoals, + getGoal, + getMyGoals, + getOrgGoalsOverview, + listGoals, + removeGoal, + updateGoal +} from '@api/services/program/goal'; import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; +import { orgTeamMemberMiddleware } from '@api/middlewares/org-team-member'; import { programMemberMiddleware } from '@api/middlewares/program-member'; import { programTeamMemberMiddleware } from '@api/middlewares/program-team-member'; import { programNewsfeedCommentAuthorOrTeamMiddleware } from '@api/middlewares/program-newsfeed-comment-author-or-team'; @@ -52,6 +70,7 @@ const ZCommentParam = z.object({ feedId: z.string().uuid(), commentId: z.coerce.number().int() }); +const ZGoalParam = z.object({ programId: z.string().uuid(), goalId: z.string().uuid() }); const ZListQuery = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(50).default(10) @@ -247,6 +266,56 @@ export const programRouter = new Hono() } ) + // ── Invite ──────────────────────────────────────────────────────────────── + + /** + * POST /program/:programId/invite + * Invite new students to a program by CSV emails. Issues org invites + * pre-tagged with this program so accept auto-enrolls them. + * Gated by program-team-member middleware — Program ADMINs/TUTORs can + * invite into their own program without org-admin rights. + */ + .post( + '/:programId/invite', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZProgramParam), + zValidator('json', ZInviteStudentsToProgram), + async (c) => { + try { + const user = c.get('user')!; + const { programId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await inviteStudentsToProgram(programId, data, user.id); + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to invite students to program'); + } + } + ) + + /** + * POST /program/:programId/invite/assign + * Assign existing org-audience student profiles to a program. + */ + .post( + '/:programId/invite/assign', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZProgramParam), + zValidator('json', ZAssignExistingStudentsToProgram), + async (c) => { + try { + const { programId } = c.req.valid('param'); + const data = c.req.valid('json'); + const result = await assignExistingStudentsToProgram(programId, data); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to assign students to program'); + } + } + ) + // ── Courses ─────────────────────────────────────────────────────────────── /** @@ -484,4 +553,190 @@ export const programRouter = new Hono() return handleError(c, error, 'Failed to delete program newsfeed comment'); } } + ) + + // ── Goals ───────────────────────────────────────────────────────────────── + + /** + * GET /program/my/goals + * Goal assignments for the current authenticated user across all their programs. + * Used by the LMS "Your Goals" widget. + */ + .get('/my/goals', authMiddleware, async (c) => { + try { + const user = c.get('user')!; + const goals = await getMyGoals(user.id); + return c.json({ success: true, data: goals }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load goals'); + } + }) + + /** + * GET /program/goals/overview?organizationId=... + * Cross-program goal roll-up for the org owner. One entry per active goal + * with per-status learner counts, scoped to the org via header + query. + */ + .get('/goals/overview', authMiddleware, orgTeamMemberMiddleware, zValidator('query', ZOrgQuery), async (c) => { + try { + const { organizationId } = c.req.valid('query'); + const overview = await getOrgGoalsOverview(organizationId); + return c.json({ success: true, data: overview }, 200); + } catch (error) { + return handleError(c, error, 'Failed to load goals overview'); + } + }) + + /** + * GET /program/:programId/goals + */ + .get('/:programId/goals', authMiddleware, programMemberMiddleware, zValidator('param', ZProgramParam), async (c) => { + try { + const { programId } = c.req.valid('param'); + const goals = await listGoals(programId); + return c.json({ success: true, data: goals }, 200); + } catch (error) { + return handleError(c, error, 'Failed to list program goals'); + } + }) + + /** + * POST /program/:programId/goals + */ + .post( + '/:programId/goals', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZProgramParam), + zValidator('json', ZCreateProgramGoal), + async (c) => { + try { + const user = c.get('user')!; + const { programId } = c.req.valid('param'); + const data = c.req.valid('json'); + const goal = await createGoal(programId, user.id, data); + return c.json({ success: true, data: goal }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create program goal'); + } + } + ) + + /** + * GET /program/:programId/goals/:goalId + */ + .get( + '/:programId/goals/:goalId', + authMiddleware, + programMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await getGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to get program goal'); + } + } + ) + + /** + * PUT /program/:programId/goals/:goalId + */ + .put( + '/:programId/goals/:goalId', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZGoalParam), + zValidator('json', ZUpdateProgramGoal), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const data = c.req.valid('json'); + const goal = await updateGoal(goalId, data); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update program goal'); + } + } + ) + + /** + * DELETE /program/:programId/goals/:goalId + * Hard delete (vs. archive). Use PUT with status=archived to preserve history. + */ + .delete( + '/:programId/goals/:goalId', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await removeGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to delete program goal'); + } + } + ) + + /** + * POST /program/:programId/goals/:goalId/archive + */ + .post( + '/:programId/goals/:goalId/archive', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const goal = await archiveGoal(goalId); + return c.json({ success: true, data: goal }, 200); + } catch (error) { + return handleError(c, error, 'Failed to archive program goal'); + } + } + ) + + /** + * POST /program/:programId/goals/:goalId/evaluate + * Force a re-evaluation of one goal's per-learner statuses. + */ + .post( + '/:programId/goals/:goalId/evaluate', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZGoalParam), + async (c) => { + try { + const { goalId } = c.req.valid('param'); + const result = await evaluateGoal(goalId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to evaluate program goal'); + } + } + ) + + /** + * POST /program/:programId/goals/evaluate-all + * Force a re-evaluation of every active goal in the program. + */ + .post( + '/:programId/goals/evaluate-all', + authMiddleware, + programTeamMemberMiddleware, + zValidator('param', ZProgramParam), + async (c) => { + try { + const { programId } = c.req.valid('param'); + const result = await evaluateProgramGoals(programId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to evaluate program goals'); + } + } ); diff --git a/apps/api/src/services/account/grace.ts b/apps/api/src/services/account/grace.ts new file mode 100644 index 00000000..634a67fd --- /dev/null +++ b/apps/api/src/services/account/grace.ts @@ -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 { + 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 { + return clearSecondariesReadOnly(primaryOrgId); +} diff --git a/apps/api/src/services/account/index.ts b/apps/api/src/services/account/index.ts new file mode 100644 index 00000000..e39a26bf --- /dev/null +++ b/apps/api/src/services/account/index.ts @@ -0,0 +1,4 @@ +export * from './profile'; +export * from './workspaces'; +export * from './quota'; +export * from './grace'; diff --git a/apps/api/src/services/account.ts b/apps/api/src/services/account/profile.ts similarity index 100% rename from apps/api/src/services/account.ts rename to apps/api/src/services/account/profile.ts diff --git a/apps/api/src/services/account/quota.ts b/apps/api/src/services/account/quota.ts new file mode 100644 index 00000000..634a9857 --- /dev/null +++ b/apps/api/src/services/account/quota.ts @@ -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 { + 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`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(); + for (const row of learnerRows) { + learnerByOrg.set(row.organizationId, row.c); + } + + const tokensByOrg = new Map(); + 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 + }; +} diff --git a/apps/api/src/services/account/workspaces.ts b/apps/api/src/services/account/workspaces.ts new file mode 100644 index 00000000..2369b524 --- /dev/null +++ b/apps/api/src/services/account/workspaces.ts @@ -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 { + 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 { + 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 { + 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 { + 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) + }; +} diff --git a/apps/api/src/services/agent/agent-tool-schemas.ts b/apps/api/src/services/agent/agent-tool-schemas.ts index 11007246..ad82b406 100644 --- a/apps/api/src/services/agent/agent-tool-schemas.ts +++ b/apps/api/src/services/agent/agent-tool-schemas.ts @@ -1,5 +1,11 @@ import { z } from 'zod'; import { CoursePlanSchema } from '@cio/ai-assistant'; +import { + LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT, + LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT, + LANDING_PAGE_SECTION_HTML_AGENT_HINT +} from '@cio/ai-assistant/tools'; +import { QUESTION_TYPE_REGISTRY } from '@cio/question-types'; import { ZExerciseSectionAfterBehavior } from '@cio/utils/validation/exercise'; import { ZCourseLandingPageUpdate, ZCourseLandingPageMetadataUpdate } from '@cio/utils/validation/course'; @@ -56,7 +62,14 @@ export const updateContentParam = z.object({ export const questionSchema = z.object({ question: z.string().min(1), - questionTypeId: z.number().int().min(1).max(14).default(1), + questionTypeId: z + .number() + .int() + .min(1) + .max(QUESTION_TYPE_REGISTRY.length) + .describe( + 'Required. Use the numeric question type IDs from the teacher system prompt (Question Types). Omitting this field is invalid — set an explicit type on every question and vary types within each exercise.' + ), points: z.number().min(0).default(1), order: z.number().int().min(0), options: z.array(z.object({ label: z.string().min(1), isCorrect: z.boolean() })) @@ -150,30 +163,62 @@ export const addQuestionsParam = z.object({ questions: z.array(questionSchema) }); +export const updateQuestionSettingsSchema = z + .object({ + correctValue: z.union([z.number(), z.string(), z.boolean()]).optional(), + tolerance: z.number().min(0).optional(), + correctAnswers: z.array(z.string().min(1)).optional(), + template: z.string().min(1).optional(), + maxStars: z.number().int().min(1).optional(), + caseSensitive: z.boolean().optional(), + distractors: z.array(z.string().min(1)).optional() + }) + .passthrough() + .refine((settings) => Object.keys(settings).length > 0, { + message: 'Provide at least one settings field' + }) + .describe( + 'Per-type correct-answer storage. NUMERIC: { correctValue: number, tolerance?: number }. STAR: { correctValue: number }. WORD_BANK: { correctAnswers: string[], template: string }.' + ); + export const updateOptionSchema = z.object({ id: z.number().int().optional(), label: z.string().min(1).optional(), isCorrect: z.boolean().optional(), - settings: z.record(z.string(), z.unknown()).optional() + settings: updateQuestionSettingsSchema.optional() }); -export const updateQuestionPatchSchema = z.object({ - id: z.number().int(), - question: z.string().min(1).optional(), - questionTypeId: z.number().int().min(1).max(14).optional(), - points: z.number().min(0).optional(), - order: z.number().int().min(0).optional(), - exerciseSectionId: z - .string() - .uuid() - .nullable() - .optional() - .describe( - 'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.' - ), - settings: z.record(z.string(), z.unknown()).optional(), - options: z.array(updateOptionSchema).optional() -}); +export const updateQuestionPatchSchema = z + .object({ + id: z.number().int(), + question: z.string().min(1).optional(), + questionTypeId: z.number().int().min(1).max(QUESTION_TYPE_REGISTRY.length).optional(), + points: z.number().min(0).optional(), + order: z.number().int().min(0).optional(), + exerciseSectionId: z + .string() + .uuid() + .nullable() + .optional() + .describe( + 'Move the question to this in-exercise block (get_exercise_details sections[].id). Use null to clear section assignment.' + ), + settings: updateQuestionSettingsSchema.optional(), + options: z.array(updateOptionSchema).optional() + }) + .refine( + (patch) => + patch.question !== undefined || + patch.questionTypeId !== undefined || + patch.points !== undefined || + patch.order !== undefined || + patch.exerciseSectionId !== undefined || + patch.settings !== undefined || + patch.options !== undefined, + { + message: 'Provide at least one field to update besides id' + } + ); export const updateQuestionsParam = z.object({ exerciseId: z.string(), @@ -181,6 +226,37 @@ export const updateQuestionsParam = z.object({ }); export const coursePlanParam = z.object({ plan: CoursePlanSchema }); +const courseTemplateIdParam = z.enum(['product_101', 'product_onboarding', 'expert_on_x']); + +const agentTemplateFormFieldBase = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + required: z.boolean().optional(), + placeholder: z.string().optional() +}); + +const agentTemplateFormFieldParam = z.discriminatedUnion('type', [ + agentTemplateFormFieldBase.extend({ type: z.literal('text') }), + agentTemplateFormFieldBase.extend({ type: z.literal('textarea') }), + agentTemplateFormFieldBase.extend({ type: z.literal('url') }), + agentTemplateFormFieldBase.extend({ + type: z.literal('select'), + options: z.array(z.object({ value: z.string().min(1), label: z.string().min(1) })).min(1) + }) +]); + +export const askTemplateQuestionsParam = z.object({ + templateId: courseTemplateIdParam, + title: z.string().min(1), + description: z.string().optional(), + fields: z.array(agentTemplateFormFieldParam).min(1) +}); + +export const fetchDocumentationUrlParam = z.object({ + url: z.string().url() +}); + // Gemini's tool-schema validator only accepts string enums, so numeric/boolean // `z.literal` values must be relaxed to plain types in the schemas the model sees. export const agentLessonTabsOrder = z.array( @@ -190,9 +266,36 @@ export const agentLessonTabsOrder = z.array( }) ); export const agentLandingPageMetadataUpdate = ZCourseLandingPageMetadataUpdate.extend({ - lessonTabsOrder: agentLessonTabsOrder.optional() + lessonTabsOrder: agentLessonTabsOrder.optional(), + requirements: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT), + description: z.string().optional().describe(LANDING_PAGE_METADATA_DESCRIPTION_SECTION_HINT), + goals: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT), + instructor: z + .object({ + name: z.string().optional(), + role: z.string().optional(), + coursesNo: z.number().optional(), + description: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT), + imgUrl: z.string().optional() + }) + .optional() }); export const updateCourseLandingPageParam = ZCourseLandingPageUpdate.extend({ + title: z.string().min(1).optional().describe('Plain-text public course title (no HTML).'), + description: z.string().min(1).optional().describe(LANDING_PAGE_COURSE_DESCRIPTION_PLAIN_HINT), + overview: z.string().optional().describe(LANDING_PAGE_SECTION_HTML_AGENT_HINT), + generateImage: z + .boolean() + .optional() + .describe( + 'Set true to auto-resolve a banner image from Unsplash. The server searches Unsplash using imageQuery if provided, otherwise the course title. Use this to fix a missing-banner blocker — do NOT ask the teacher to describe an image.' + ), + imageQuery: z + .string() + .min(1) + .max(120) + .optional() + .describe('Optional Unsplash search query (1–120 chars). Omit to let the server use the course title.'), metadata: agentLandingPageMetadataUpdate.optional() }).refine( (data) => diff --git a/apps/api/src/services/agent/chat-context.ts b/apps/api/src/services/agent/chat-context.ts index 867151b6..468acd46 100644 --- a/apps/api/src/services/agent/chat-context.ts +++ b/apps/api/src/services/agent/chat-context.ts @@ -3,7 +3,7 @@ import { getDocumentText } from '@api/services/agent/document'; import { redis } from '@api/utils/redis/redis'; import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent'; import { z } from 'zod'; -import { CoursePlanSchema } from '@cio/ai-assistant'; +import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant'; type ResourceOwnershipRow = { courseId: string | null; @@ -137,7 +137,7 @@ type PlanMetadataMessage = { }; }; -export function getLatestImplementationPlan(messages: unknown[]): z.infer | undefined { +export function getLatestImplementationPlan(messages: unknown[]): z.infer | undefined { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] as PlanMetadataMessage; @@ -149,7 +149,7 @@ export function getLatestImplementationPlan(messages: unknown[]): z.infer(['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; +} diff --git a/apps/api/src/services/agent/chat-history.ts b/apps/api/src/services/agent/chat-history.ts index 355a3e60..bf92bd6c 100644 --- a/apps/api/src/services/agent/chat-history.ts +++ b/apps/api/src/services/agent/chat-history.ts @@ -2,23 +2,153 @@ import { listChatConversations, getChatConversation, createChatConversation, - saveChatMessages, + saveChatMessages as saveDbChatMessages, deleteChatConversation, updateConversationTitle, getChatHistory, - saveChatHistory, + saveChatHistory as saveDbChatHistory, deleteChatHistory } from '@cio/db/queries/agent'; +const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000; +const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']); +const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']); + +function isRecord(value: unknown): value is Record { + 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 | 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 { + await saveDbChatMessages(conversationId, userId, sanitizeChatMessages(messages), title); +} + +async function persistChatHistory(courseId: string, userId: string, messages: unknown[]): Promise { + await saveDbChatHistory(courseId, userId, sanitizeChatMessages(messages)); +} + export { listChatConversations, getChatConversation, createChatConversation, - saveChatMessages, deleteChatConversation, updateConversationTitle, // Legacy compat getChatHistory as fetchChatHistory, - saveChatHistory as persistChatHistory, + persistChatHistory, deleteChatHistory as clearChatHistory }; diff --git a/apps/api/src/services/agent/chat-tools.ts b/apps/api/src/services/agent/chat-tools.ts index 3b01671d..23686c6c 100644 --- a/apps/api/src/services/agent/chat-tools.ts +++ b/apps/api/src/services/agent/chat-tools.ts @@ -28,6 +28,7 @@ import { } from '@api/services/agent/chat-context'; import { addQuestionsParam, + askTemplateQuestionsParam, coursePlanParam, createExerciseParam, createExerciseSectionParam, @@ -35,6 +36,7 @@ import { createSectionParam, emptyParam, exerciseReadParam, + fetchDocumentationUrlParam, goLiveParam, lessonReadParam, reorderContentParam, @@ -46,6 +48,7 @@ import { updateQuestionsParam, updateSectionParam } from '@api/services/agent/agent-tool-schemas'; +import { fetchDocumentationUrl } from '@api/services/agent/fetch-url'; function summarizeAgentDebugValue(value: unknown, depth = 0): unknown { if (value == null) return value; @@ -184,7 +187,7 @@ async function executeAgentTool( } } -export function buildAgentTools(orgId: string, userId: string, courseId: string) { +export function buildAgentTools(orgId: string, userId: string, courseId: string, priorMessages: unknown[]) { return { get_course_structure: tool({ description: @@ -316,6 +319,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string) }); return { lessonId: args.lessonId, + lessonTitle: lesson.title, locale: args.locale, contentLength: normalizedContent.length, updated: true @@ -488,7 +492,12 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string) ]; await updateExerciseService(args.exerciseId, { questions: allQuestions }); - return { exerciseId: args.exerciseId, addedCount: args.questions.length, totalCount: allQuestions.length }; + return { + exerciseId: args.exerciseId, + exerciseTitle: existingExercise.title, + addedCount: args.questions.length, + totalCount: allQuestions.length + }; }); } }), @@ -545,7 +554,11 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string) await updateExerciseService(args.exerciseId, { questions: merged }); - return { exerciseId: args.exerciseId, updatedCount: merged.length }; + return { + exerciseId: args.exerciseId, + exerciseTitle: existing.title, + updatedCount: merged.length + }; }); } }), @@ -586,7 +599,7 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string) update_course_landing_page: tool({ description: - 'Update course-level landing page fields for this course, including public title, description, overview, goals, requirements, instructor metadata, pricing, and banner image. The courseId is automatically set — do not pass it.', + 'Update course-level landing page fields for this course (public title, course description, overview, goals, requirements, the Description section after Requirements, instructor bio, pricing, banner image). The top-level course description field is plain text only—no HTML. All other narrative sections (overview, metadata goals/requirements, metadata description for the block after Requirements, instructor description, etc.) are HTML: paragraphs, lists, line breaks, bold, and italic only—never heading tags (h1–h6) because the UI shows section titles. Title is plain text. The courseId is automatically set — do not pass it.', inputSchema: updateCourseLandingPageParam, execute: async (args) => { return executeAgentTool('update_course_landing_page', { orgId, userId, courseId, args }, async () => { @@ -650,6 +663,42 @@ export function buildAgentTools(orgId: string, userId: string, courseId: string) return plan; }); } + }), + + ask_template_questions: tool({ + description: + 'Render a structured questionnaire card to the teacher. Pause until the teacher submits via metadata.template.action submit_template_answers or skips via skip_template_form.', + inputSchema: askTemplateQuestionsParam, + execute: async (args) => { + return executeAgentTool( + 'ask_template_questions', + { orgId, userId, courseId, args }, + async () => + ({ + awaiting_user: true as const, + templateId: args.templateId, + title: args.title, + description: args.description, + fields: args.fields + }) as const + ); + } + }), + + fetch_documentation_url: tool({ + description: + 'Fetch a public documentation URL via Jina Reader. Returns markdown wrapped as untrusted external content plus same-origin links for follow-up fetches.', + inputSchema: fetchDocumentationUrlParam, + execute: async (args) => { + return executeAgentTool('fetch_documentation_url', { orgId, userId, courseId, args }, async () => { + return fetchDocumentationUrl({ + url: args.url, + orgId, + courseId, + priorMessages + }); + }); + } }) }; } diff --git a/apps/api/src/services/agent/compact.ts b/apps/api/src/services/agent/compact.ts new file mode 100644 index 00000000..44fa2649 --- /dev/null +++ b/apps/api/src/services/agent/compact.ts @@ -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] }; +} diff --git a/apps/api/src/services/agent/fetch-url.ts b/apps/api/src/services/agent/fetch-url.ts new file mode 100644 index 00000000..11377244 --- /dev/null +++ b/apps/api/src/services/agent/fetch-url.ts @@ -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(); + 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(/\n${markdown}\n`; +} + +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; + 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 { + 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 { + 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 { + 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 = {}; + + 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 { + 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 + }; +} diff --git a/apps/api/src/services/agent/sanitize-tool-calls.ts b/apps/api/src/services/agent/sanitize-tool-calls.ts new file mode 100644 index 00000000..772ae3d0 --- /dev/null +++ b/apps/api/src/services/agent/sanitize-tool-calls.ts @@ -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(); + 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(); + 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; +} diff --git a/apps/api/src/services/agent/student-tools.ts b/apps/api/src/services/agent/student-tools.ts new file mode 100644 index 00000000..e353d2cb --- /dev/null +++ b/apps/api/src/services/agent/student-tools.ts @@ -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( + toolName: string, + ctx: { orgId: string; userId: string; courseId: string; args?: unknown }, + execute: () => Promise +): Promise { + 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; diff --git a/apps/api/src/services/agent/summarize.ts b/apps/api/src/services/agent/summarize.ts new file mode 100644 index 00000000..7c352575 --- /dev/null +++ b/apps/api/src/services/agent/summarize.ts @@ -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 | 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 { + 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(); +} diff --git a/apps/api/src/services/agent/text-generation.ts b/apps/api/src/services/agent/text-generation.ts new file mode 100644 index 00000000..fcdb8d1e --- /dev/null +++ b/apps/api/src/services/agent/text-generation.ts @@ -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.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 { + const model = createTextGenModel(providerConfig); + const modelName = TEXT_GEN_MODELS[providerConfig.provider]; + + const contextLine = context ? `Context: ${context}.` : ''; + const formatLine = + format === 'html' + ? 'Return valid HTML (use

,

    ,
  • , 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 + }; +} diff --git a/apps/api/src/services/agent/title-generation.ts b/apps/api/src/services/agent/title-generation.ts index d4d76807..b2f8d465 100644 --- a/apps/api/src/services/agent/title-generation.ts +++ b/apps/api/src/services/agent/title-generation.ts @@ -11,7 +11,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'; const TITLE_MODELS: Record = { [AIProvider.OPENAI]: 'gpt-4o-mini', [AIProvider.ANTHROPIC]: 'claude-haiku-4-5-20251001', - [AIProvider.GOOGLE]: 'gemini-2.5-flash', + [AIProvider.GOOGLE]: 'gemini-3.1-flash-lite', [AIProvider.MOONSHOT]: 'kimi-k2.6' }; @@ -55,7 +55,7 @@ export async function generateConversationTitle( const truncatedMessage = firstMessageText.slice(0, 1000); - // Gemini 2.5 counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text. + // Gemini 3.x counts thinking toward maxOutputTokens; disable thinking and allow headroom for the title text. const { text } = await generateText( providerConfig.provider === AIProvider.GOOGLE ? { diff --git a/apps/api/src/services/agent/tutor-config.ts b/apps/api/src/services/agent/tutor-config.ts new file mode 100644 index 00000000..52586d0d --- /dev/null +++ b/apps/api/src/services/agent/tutor-config.ts @@ -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 { + const [orgSettings, courseOverride] = await Promise.all([ + getOrgAiTutorSettings(orgId), + getCourseAiTutorOverride(courseId) + ]); + + const orgResolved = mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial | null); + + if (!courseOverride) return orgResolved; + if (courseOverride.inheritFromOrg === true) return orgResolved; + + const { inheritFromOrg: _drop, ...override } = courseOverride; + return mergeAiTutorSettings(orgResolved, override as Partial); +} + +export async function getResolvedOrgAiTutorSettings(orgId: string): Promise { + const orgSettings = await getOrgAiTutorSettings(orgId); + return mergeAiTutorSettings(defaultAiTutorSettings, orgSettings as Partial | null); +} + +export async function updateOrgAiTutorSettingsService( + orgId: string, + patch: Partial +): Promise { + const updated = await updateOrgAiTutorSettings(orgId, patch); + + if (!updated) { + throw new AppError('Organization not found', 'ORGANIZATION_NOT_FOUND', 404); + } + + return mergeAiTutorSettings(defaultAiTutorSettings, updated as Partial); +} + +export interface CourseAiTutorSettingsView { + org: AiTutorSettings; + override: (Partial & { inheritFromOrg?: boolean }) | null; + effective: AiTutorSettings; +} + +export async function getCourseAiTutorSettingsView( + orgId: string, + courseId: string +): Promise { + const [org, override, effective] = await Promise.all([ + getResolvedOrgAiTutorSettings(orgId), + getCourseAiTutorOverride(courseId), + getEffectiveAiTutorSettings(orgId, courseId) + ]); + + return { + org, + override: (override as (Partial & { inheritFromOrg?: boolean }) | null) ?? null, + effective + }; +} + +export async function updateCourseAiTutorOverrideService( + courseId: string, + patch: Partial & { inheritFromOrg?: boolean } +): Promise & { inheritFromOrg?: boolean }> { + const updated = await updateCourseAiTutorOverride(courseId, patch); + + if (!updated) { + throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404); + } + + return updated as Partial & { inheritFromOrg?: boolean }; +} diff --git a/apps/api/src/services/agent/tutor-usage.ts b/apps/api/src/services/agent/tutor-usage.ts new file mode 100644 index 00000000..d5495d3b --- /dev/null +++ b/apps/api/src/services/agent/tutor-usage.ts @@ -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 { + 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 { + 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() + }; +} diff --git a/apps/api/src/services/agent/usage.ts b/apps/api/src/services/agent/usage.ts index 928f2682..06a50ec2 100644 --- a/apps/api/src/services/agent/usage.ts +++ b/apps/api/src/services/agent/usage.ts @@ -32,13 +32,15 @@ const PLAN_TOKEN_ALLOWANCES: Record = { // ─── 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 = { - 'gemini-2.5-flash': 1, - 'gpt-5.4-mini': 3, + 'gemini-3.1-flash-lite': 1, + 'gemini-2.5-flash': 1.5, + 'gpt-5.4-mini': 4, 'claude-sonnet-4-6': 11, - 'claude-haiku-4-5-20251001': 1, - 'kimi-k2.6': 3 + 'claude-haiku-4-5-20251001': 1.5, + 'kimi-k2.6': 4 }; function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number { diff --git a/apps/api/src/services/analytics/index.ts b/apps/api/src/services/analytics/index.ts new file mode 100644 index 00000000..0067fe82 --- /dev/null +++ b/apps/api/src/services/analytics/index.ts @@ -0,0 +1,2 @@ +export * from './ingest'; +export * from './reads'; diff --git a/apps/api/src/services/analytics/ingest.ts b/apps/api/src/services/analytics/ingest.ts new file mode 100644 index 00000000..25763439 --- /dev/null +++ b/apps/api/src/services/analytics/ingest.ts @@ -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 + })); + + const inserted = await insertPageEvents(rows); + return { inserted }; +} diff --git a/apps/api/src/services/analytics/reads.ts b/apps/api/src/services/analytics/reads.ts new file mode 100644 index 00000000..6821eec0 --- /dev/null +++ b/apps/api/src/services/analytics/reads.ts @@ -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(key: string): Promise { + 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 { + 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 { + const key = dashAnalyticsKey('landing', orgId, days); + if (!bustCache) { + const cached = await readCache(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 { + const key = dashAnalyticsKey('country', orgId, days); + if (!bustCache) { + const cached = await readCache(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 { + const key = dashAnalyticsKey('funnel', orgId, days, courseId); + if (!bustCache) { + const cached = await readCache(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 { + const key = dashAnalyticsKey('popular-types', orgId, days); + if (!bustCache) { + const cached = await readCache(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; +} diff --git a/apps/api/src/services/assets/assets.ts b/apps/api/src/services/assets/assets.ts index 4ca00975..87d93338 100644 --- a/apps/api/src/services/assets/assets.ts +++ b/apps/api/src/services/assets/assets.ts @@ -9,6 +9,7 @@ import type { TAssetUpdate, TYouTubeMetadataQuery } from '@cio/utils/validation/assets'; +import type { TTranscriptResponse } from '@cio/utils/validation/media'; import { countAssetUsagesByAsset, createAssetUsage, @@ -22,6 +23,9 @@ import { listAssetUsagesByAsset, updateAsset } from '@cio/db/queries/assets'; +import { getMediaTranscriptByAsset } from '@cio/db/queries'; + +import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3'; const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/; @@ -210,7 +214,7 @@ export async function listOrganizationAssetsService(orgId: string, query: TAsset export async function createAssetFromUploadService(orgId: string, profileId: string, data: TAssetCreateUpload) { try { - return await createOrGetAssetByStorageKey({ + const asset = await createOrGetAssetByStorageKey({ organizationId: orgId, kind: data.kind, provider: data.provider, @@ -230,6 +234,19 @@ export async function createAssetFromUploadService(orgId: string, profileId: str metadata: data.metadata ?? {}, createdByProfileId: profileId }); + + // Fire-and-forget: enqueue lesson-video post-processing for new uploads. + // Errors are logged but never block the asset create response. + if (asset.kind === 'video' && asset.provider === 'upload' && asset.storageKey) { + void enqueueMediaPostProcessingForAsset({ + organizationId: orgId, + assetId: asset.id, + storageKey: asset.storageKey, + triggeredByProfileId: profileId + }); + } + + return asset; } catch (error) { throw new AppError( error instanceof Error ? error.message : 'Failed to create asset', @@ -239,6 +256,26 @@ export async function createAssetFromUploadService(orgId: string, profileId: str } } +async function enqueueMediaPostProcessingForAsset(input: { + organizationId: string; + assetId: string; + storageKey: string; + triggeredByProfileId: string; +}): Promise { + try { + const { startMediaJob } = await import('@api/services/jobs'); + await startMediaJob({ + organizationId: input.organizationId, + assetId: input.assetId, + storageKey: input.storageKey, + triggeredByProfileId: input.triggeredByProfileId, + withTranscription: Boolean(process.env.OPENAI_API_KEY) + }); + } catch (error) { + console.error('enqueueMediaPostProcessingForAsset failed:', error); + } +} + export async function getAssetService(orgId: string, assetId: string) { try { const asset = await getAssetById(assetId, orgId); @@ -394,6 +431,42 @@ export async function getOrganizationAssetStorageService(orgId: string, query: T } } +export async function getTranscriptForOrganizationAssetService( + orgId: string, + assetId: string +): Promise { + try { + const asset = assertAssetExists(await getAssetById(assetId)); + + if (asset.organizationId !== orgId) { + throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404); + } + + const row = await getMediaTranscriptByAsset(assetId, orgId); + if (!row) { + return null; + } + + const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket); + const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(); + + return { + language: row.language, + segments: row.segments, + vttUrl, + vttUrlExpiresAt, + durationSeconds: row.durationSeconds ?? null + }; + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch transcript', + ErrorCodes.ASSET_FETCH_FAILED, + 500 + ); + } +} + export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) { const videoId = extractYouTubeVideoId(query.url); if (!videoId) { diff --git a/apps/api/src/services/course/certificate.ts b/apps/api/src/services/course/certificate.ts new file mode 100644 index 00000000..225f327b --- /dev/null +++ b/apps/api/src/services/course/certificate.ts @@ -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 { + 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 { + const studentName = body.studentName.trim().length + ? body.studentName + : (await getProfileById(userId))?.fullname || 'Preview Recipient'; + + return assembleCertificateRender(courseId, { ...body, studentName }); +} diff --git a/apps/api/src/services/course/completion.ts b/apps/api/src/services/course/completion.ts index 0584a9c7..9907a3a8 100644 --- a/apps/api/src/services/course/completion.ts +++ b/apps/api/src/services/course/completion.ts @@ -16,6 +16,7 @@ import { type TCourseCertificationRow } from '@cio/db/queries/course/course'; import { ROLE } from '@cio/utils/constants'; +import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; export type CertificationBlocker = { code: @@ -240,6 +241,22 @@ export async function evaluateCourseCertification( courseRow, earnedAt }); + + trackServerEvent({ + eventType: SERVER_EVENTS.COURSE_COMPLETED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + + if (courseRow.certificate?.isDownloadable) { + trackServerEvent({ + eventType: SERVER_EVENTS.CERTIFICATE_ISSUED, + userId: profileId, + courseId, + props: { path: 'standard', earnedAt } + }); + } } return { ...evaluation, certificateEarnedAt, isNewCompletion }; diff --git a/apps/api/src/services/course/compliance.ts b/apps/api/src/services/course/compliance.ts index 2c462db0..ddaaea41 100644 --- a/apps/api/src/services/course/compliance.ts +++ b/apps/api/src/services/course/compliance.ts @@ -6,11 +6,13 @@ import { getCourseComplianceHistoryRows, getCourseCurrentComplianceRows, getLatestComplianceRecordsByProfiles, + getOrgComplianceLearnerRows, getStudentCourseMembersForCompliance, listComplianceRecordsReadyForExpiry, listLatestComplianceRecordsForReminderScan, updateCourseCertificateIssueStatusByRecordId, - updateCourseCompletionRecord + updateCourseCompletionRecord, + type OrgComplianceLearnerRow } from '@cio/db/queries/course/compliance'; import { db } from '@cio/db/drizzle'; import { getCourseById } from '@cio/db/queries/course'; @@ -712,3 +714,108 @@ export async function runComplianceReminderScan() { reminderEventCount }; } + +type OrgComplianceStatus = ComplianceStatus | 'no_record'; + +const STATUS_KEYS: OrgComplianceStatus[] = [ + 'compliant', + 'expiring_soon', + 'in_grace_period', + 'non_compliant', + 'waived', + 'in_progress', + 'not_started', + 'no_record' +]; + +function emptyStatusCounts() { + const counts = {} as Record; + for (const status of STATUS_KEYS) counts[status] = 0; + return counts; +} + +export type OrgComplianceOverview = { + summary: { + totalLearners: number; + totalCourses: number; + counts: Record; + }; + courses: Array<{ + courseId: string; + courseTitle: string; + learnerCount: number; + counts: Record; + }>; + 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 { + const rows = await getOrgComplianceLearnerRows(orgId); + + const summaryCounts = emptyStatusCounts(); + const courseMap = new Map< + string, + { title: string; learnerCount: number; counts: Record } + >(); + const uniqueLearners = new Set(); + + 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 + })) + }; +} diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index 03b2c0b0..31dbcc6d 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -29,11 +29,13 @@ import crypto from 'node:crypto'; import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth'; import { generateSlug } from '@cio/utils/functions'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; import { db } from '@cio/db/drizzle'; +import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED'; type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM'; @@ -350,17 +352,18 @@ async function sendStudentJoinEmails(input: { }) { try { const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined); - await sendEmail('studentCourseWelcome', { + await enqueueTransactionalEmail('studentCourseWelcome', { to: input.studentEmail, fields: { orgName: input.orgName, courseName: input.courseName, loginUrl }, - from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`) + from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), + idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}` }); } catch (error) { - console.error('Failed to send student welcome email:', error); + console.error('Failed to enqueue student welcome email:', error); } try { @@ -372,26 +375,22 @@ async function sendStudentJoinEmails(input: { const studentProfile = await getProfileById(input.studentId); const studentName = studentProfile?.fullname || input.studentEmail; - await Promise.all( - teachers - .map((teacher) => teacher.email) - .filter((email): email is string => !!email) - .map((teacherEmail) => - sendEmail('teacherStudentJoined', { - to: teacherEmail, - fields: { - courseName: input.courseName, - studentName, - studentEmail: input.studentEmail - }, - from: buildEmailFromName('ClassroomIO') - }).catch((error) => { - console.error(`Failed to send teacher notification email to ${teacherEmail}:`, error); - }) - ) - ); + const teacherEmails = teachers.map((teacher) => teacher.email).filter((email): email is string => !!email); + + if (teacherEmails.length === 0) return; + + await enqueueTransactionalEmail('teacherStudentJoined', { + to: teacherEmails, + fields: { + courseName: input.courseName, + studentName, + studentEmail: input.studentEmail + }, + from: buildEmailFromName('ClassroomIO'), + idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}` + }); } catch (error) { - console.error('Failed to send teacher join notification emails:', error); + console.error('Failed to enqueue teacher join notification emails:', error); } } @@ -434,7 +433,7 @@ async function createEmailInviteAndSend(input: { } try { - const responses = await sendEmail('studentCourseInvite', { + await enqueueTransactionalEmail('studentCourseInvite', { to: input.recipientEmail, fields: { orgName: input.orgName, @@ -442,29 +441,11 @@ async function createEmailInviteAndSend(input: { inviteLink: createdInvite.inviteLink, expiresAt: getExpiryLabel(createdInvite.expiresAt) }, - from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`) + from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), + idempotencyKey: `course-invite-email:${createdInvite.id}` }); - const allSuccessful = responses.every((response) => response.success); - if (!allSuccessful) { - const errorMessage = responses - .filter((response) => !response.success) - .map((response) => response.error || 'Unknown email error') - .join('; '); - - await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_FAILED', { - actorProfileId: input.createdByProfileId, - targetEmail: input.recipientEmail, - metadata: { error: errorMessage } - }); - - return { - invite: createdInvite, - sent: false, - error: errorMessage - }; - } - + // Optimistic EMAIL_SENT — see comment in services/organization/invite.ts. await recordInviteAudit(createdInvite.id, input.courseId, 'EMAIL_SENT', { actorProfileId: input.createdByProfileId, targetEmail: input.recipientEmail @@ -683,6 +664,14 @@ export async function enrollInCourse( studentEmail: normalizedEmail }); + trackServerEvent({ + eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED, + orgId: org.id, + userId: user.id, + courseId, + props: { path: 'free-enrollment' } + }); + return { success: true, alreadyJoined: false, @@ -1036,6 +1025,13 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex studentId: user.id, studentEmail: normalizedEmail }); + + trackServerEvent({ + eventType: SERVER_EVENTS.ENROLLMENT_COMPLETED, + userId: user.id, + courseId: result.courseId, + props: { path: 'invite' } + }); } return { diff --git a/apps/api/src/services/course/payment-request.ts b/apps/api/src/services/course/payment-request.ts index 6f751670..cad8120b 100644 --- a/apps/api/src/services/course/payment-request.ts +++ b/apps/api/src/services/course/payment-request.ts @@ -2,7 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseWithOrgData } from '@cio/db/queries/course'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; +import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; export interface PaymentRequestData { courseId: string; @@ -40,25 +42,23 @@ export async function createPaymentRequest(data: PaymentRequestData) { const teacherEmail = teacherResult[0].email; - // Send email to teacher about student buy request try { - await sendEmail('teacherStudentBuyRequest', { + await enqueueTransactionalEmail('teacherStudentBuyRequest', { to: teacherEmail, fields: { courseName, studentEmail: data.studentEmail, studentFullname: data.studentFullname }, - from: buildEmailFromName('ClassroomIO') + from: buildEmailFromName('ClassroomIO'), + idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}` }); } catch (emailError) { - console.error('Failed to send teacher buy request email:', emailError); - // Don't fail the request if email fails + console.error('Failed to enqueue teacher buy request email:', emailError); } - // Send email to student with payment instructions try { - await sendEmail('studentProvePayment', { + await enqueueTransactionalEmail('studentProvePayment', { to: data.studentEmail, fields: { courseName, @@ -67,13 +67,19 @@ export async function createPaymentRequest(data: PaymentRequestData) { orgName }, from: buildEmailFromName(`${orgName} - ClassroomIO`), - replyTo: teacherEmail + replyTo: teacherEmail, + idempotencyKey: `payment-request:student:${data.courseId}:${data.studentEmail}` }); } catch (emailError) { - console.error('Failed to send student payment proof email:', emailError); - // Don't fail the request if email fails + console.error('Failed to enqueue student payment proof email:', emailError); } + trackServerEvent({ + eventType: SERVER_EVENTS.ENROLLMENT_STARTED, + courseId: data.courseId, + props: { path: 'payment-request', orgName } + }); + return { success: true, courseName, diff --git a/apps/api/src/services/course/people.ts b/apps/api/src/services/course/people.ts index e3b2a413..e194fd7b 100644 --- a/apps/api/src/services/course/people.ts +++ b/apps/api/src/services/course/people.ts @@ -14,7 +14,8 @@ import type { TGroupmember } from '@cio/db/types'; import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; /** @@ -85,45 +86,44 @@ export async function addMember( studentName = data.name || null; } - // Send welcome email to student if (studentEmail) { try { - await sendEmail('studentCourseWelcome', { + await enqueueTransactionalEmail('studentCourseWelcome', { to: studentEmail, fields: { orgName, courseName, loginUrl }, - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`) + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}` }); } catch (emailError) { - console.error('Failed to send student welcome email:', emailError); + console.error('Failed to enqueue student welcome email:', emailError); } } - // Get all teachers (ADMIN or TUTOR) in the course const teachersResult = await getCourseTeachers({ courseId }); - // Send notification to all teachers if (teachersResult.length > 0 && studentEmail && studentName) { const teacherEmails = teachersResult.map((t) => t.email).filter((email): email is string => email !== null); - await Promise.all( - teacherEmails.map((teacherEmail) => - sendEmail('teacherStudentJoined', { - to: teacherEmail, + if (teacherEmails.length > 0) { + try { + await enqueueTransactionalEmail('teacherStudentJoined', { + to: teacherEmails, fields: { courseName, studentName, studentEmail }, - from: buildEmailFromName('ClassroomIO') - }).catch((emailError) => { - console.error(`Failed to send teacher notification email to ${teacherEmail}:`, emailError); - }) - ) - ); + from: buildEmailFromName('ClassroomIO'), + idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}` + }); + } catch (emailError) { + console.error('Failed to enqueue teacher notification email:', emailError); + } + } } } } catch (emailError) { @@ -193,19 +193,17 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { const baseUrl = getDashboardBaseUrl(orgSiteName); const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`; - // Send welcome emails to teachers const teacherEmailPromises: Promise[] = []; addedMembers.forEach((member, index) => { const memberData = members[index]; - // Only send emails to teachers (TUTOR or ADMIN role) who have email const roleId = memberData.roleId; const email = memberData.email; const name = memberData.name; if ((roleId === ROLE.ADMIN || roleId === ROLE.TUTOR) && email && name) { teacherEmailPromises.push( - sendEmail('teacherCourseWelcome', { + enqueueTransactionalEmail('teacherCourseWelcome', { to: email, fields: { name, @@ -213,9 +211,10 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { courseName, inviteLink }, - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`) + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + idempotencyKey: `teacher-course-welcome:${courseId}:${email}` }).catch((emailError) => { - console.error(`Failed to send welcome email to ${email}:`, emailError); + console.error(`Failed to enqueue welcome email to ${email}:`, emailError); return []; }) ); @@ -226,8 +225,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { try { await Promise.all(teacherEmailPromises); } catch (emailError) { - // Log but don't fail the request if email fails - console.error('Failed to send welcome emails:', emailError); + console.error('Failed to enqueue welcome emails:', emailError); } } diff --git a/apps/api/src/services/course/public-course.ts b/apps/api/src/services/course/public-course.ts index b4520285..effb3d01 100644 --- a/apps/api/src/services/course/public-course.ts +++ b/apps/api/src/services/course/public-course.ts @@ -1,4 +1,12 @@ import { AppError, ErrorCodes } from '@api/utils/errors'; +import { + generateTranscriptVttPresignedUrl, + generateVideoDownloadPresignedUrls, + TRANSCRIPT_VTT_PRESIGN_SECONDS +} from '@api/utils/s3'; +import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils'; +import { getMediaTranscriptByAsset } from '@cio/db/queries'; +import { getAssetsByIds } from '@cio/db/queries/assets'; import { getCourseRowBySlug, getPublicCourseItem as getPublicCourseItemQuery, @@ -8,7 +16,6 @@ import { } from '@cio/db/queries/course'; import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise'; import { getExerciseSectionsByExerciseId } from '@cio/db/queries/exercise/exercise-section'; -import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils'; /** * Anonymous-safe: fetch the full tree for a published public course by slug. @@ -24,7 +31,7 @@ export async function getPublicCourseTreeService(courseSlug: string): Promise & { kind: 'lesson' }) + | (Omit, 'courseOrganizationId'> & { kind: 'lesson' }) | (Extract & { kind: 'exercise'; questions: PublicExercisePayload; @@ -74,7 +81,69 @@ export async function getPublicCourseItemService( if (!baseItem) return null; if (baseItem.kind === 'lesson') { - return baseItem; + const { courseOrganizationId, ...lesson } = baseItem; + + let video = lesson.video; + + if (video?.type === 'upload') { + let storageKey: string | undefined; + + if (video.assetId) { + const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined); + storageKey = assets[0]?.storageKey ?? undefined; + } + + if (!storageKey && video.key) { + storageKey = video.key; + } + + if (storageKey) { + const urls = await generateVideoDownloadPresignedUrls([storageKey]); + const signedLink = urls[storageKey]; + + if (signedLink) { + const { key: _omitKey, ...rest } = video; + video = { ...rest, link: signedLink }; + } + } + } + + if (video?.assetId && courseOrganizationId) { + const transcriptRow = await getMediaTranscriptByAsset(video.assetId, courseOrganizationId); + let transcript: { + vttUrl: string; + vttUrlExpiresAt: string; + language: string; + } | null = null; + + if (transcriptRow) { + const vttUrl = await generateTranscriptVttPresignedUrl(transcriptRow.vttStorageKey, transcriptRow.vttBucket); + transcript = { + vttUrl, + vttUrlExpiresAt: new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString(), + language: transcriptRow.language + }; + } + + video = { ...video, transcript }; + } + + if (video?.key !== undefined) { + const { key: _omitS3Key, ...withoutKey } = video; + video = withoutKey; + } + + return { ...lesson, video }; + } + + if (!baseItem.isUnlocked) { + return { + ...baseItem, + questions: { + unsectioned: [], + sections: [] + } + }; } const { exercise, questions } = await getExerciseWithRelationsOptimized(baseItem.id); diff --git a/apps/api/src/services/jobs/email-jobs.ts b/apps/api/src/services/jobs/email-jobs.ts new file mode 100644 index 00000000..7ec349fd --- /dev/null +++ b/apps/api/src/services/jobs/email-jobs.ts @@ -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:`). When omitted the job gets an auto-generated id, so + * duplicate calls produce duplicate emails. + */ + idempotencyKey?: string; +} + +export interface EnqueueTemplateEmailInput extends CommonOptions { + to: Recipient; + fields: z.infer>; + 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( + template: TId, + input: EnqueueTemplateEmailInput +): Promise { + 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; + + 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 { + 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 }; +} diff --git a/apps/api/src/services/jobs/index.ts b/apps/api/src/services/jobs/index.ts new file mode 100644 index 00000000..dc9d3b79 --- /dev/null +++ b/apps/api/src/services/jobs/index.ts @@ -0,0 +1,2 @@ +export * from './media-jobs'; +export * from './email-jobs'; diff --git a/apps/api/src/services/jobs/media-jobs.ts b/apps/api/src/services/jobs/media-jobs.ts new file mode 100644 index 00000000..559fa6cc --- /dev/null +++ b/apps/api/src/services/jobs/media-jobs.ts @@ -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 { + 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 { + 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 { + 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> { + if (jobIds.length === 0) return {}; + + const rows = await listMediaJobsByIds(jobIds, orgId); + const map: Record = {}; + for (const row of rows) { + map[row.id] = toEnvelope(row); + } + + return map; +} + +export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise { + const rows = await listLatestMediaJobsByAsset(assetId, orgId); + return rows.map((row) => toEnvelope(row)); +} + +export async function cancelMediaJob(jobId: string, orgId: string): Promise { + 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); +} diff --git a/apps/api/src/services/newsfeed/newsfeed.ts b/apps/api/src/services/newsfeed/newsfeed.ts index 0763dc8e..c50b94e3 100644 --- a/apps/api/src/services/newsfeed/newsfeed.ts +++ b/apps/api/src/services/newsfeed/newsfeed.ts @@ -18,7 +18,8 @@ import { } from '@cio/db/queries/newsfeed'; import { env } from '@api/config/env'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; /** * Lists newsfeed items for a course @@ -359,25 +360,25 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) { const orgSiteName = feedData.organization?.siteName || 'app'; const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`; - // Send email to all course members - const emailPromises = feedData.courseMembers.map((member) => { - if (!member.email) return Promise.resolve(); + const recipients = feedData.courseMembers + .map((member) => member.email) + .filter((email): email is string => Boolean(email)); - return sendEmail('newsfeedPost', { - to: member.email, - fields: { - courseTitle: feedData.courseTitle!, - teacherName: feedData.author?.fullname || 'A teacher', - content: feedData.content || '', - postLink, - orgName - }, - from: buildEmailFromName(`${orgName} - ClassroomIO`), - replyTo: feedData.author?.email || 'noreply@classroomio.com' - }); + if (recipients.length === 0) return; + + await enqueueTransactionalEmail('newsfeedPost', { + to: recipients, + fields: { + courseTitle: feedData.courseTitle!, + teacherName: feedData.author?.fullname || 'A teacher', + content: feedData.content || '', + postLink, + orgName + }, + from: buildEmailFromName(`${orgName} - ClassroomIO`), + replyTo: feedData.author?.email || 'noreply@classroomio.com', + idempotencyKey: `newsfeed:post:${feedId}` }); - - await Promise.all(emailPromises); } catch (error) { console.error('Error sending newsfeed post email:', error); // Don't throw - email failures shouldn't break the API @@ -403,7 +404,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string) const orgSiteName = feedData.organization?.siteName || 'app'; const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`; - await sendEmail('newsfeedComment', { + await enqueueTransactionalEmail('newsfeedComment', { to: feedData.author.email, fields: { courseTitle: feedData.courseTitle, diff --git a/apps/api/src/services/onboarding.ts b/apps/api/src/services/onboarding.ts index bc4f8ead..4f7027a8 100644 --- a/apps/api/src/services/onboarding.ts +++ b/apps/api/src/services/onboarding.ts @@ -14,7 +14,7 @@ import { env } from '@api/config/env'; import { ROLE } from '@cio/utils/constants'; import { PLAN } from '@cio/utils/plans'; import { db } from '@cio/db/drizzle'; -import { sendEmail } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; export async function createOrganizationWithOwner( profileId: string, @@ -125,11 +125,12 @@ export async function completeOnboarding(user: Partial & { id: string; em throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404); } - await sendEmail('welcome', { + await enqueueTransactionalEmail('welcome', { to: user.email, fields: { name: profile.fullname - } + }, + idempotencyKey: `onboarding:welcome:${user.id}` }); } catch (error) { if (error instanceof AppError) { diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index 09d1c65d..a6d3db8e 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -32,6 +32,7 @@ import { getPublishedCoursesBySiteName } from '@cio/db/queries/course'; import { getCourseIdsByTagSlugs, getCourseTagsByCourseIdsForOrganization } from '@cio/db/queries/tag'; +import { getAccountPrimary } from '@cio/db/queries/account'; import { getLastLogin, getProfileCourseProgress, getUserExercisesStats } from '@cio/db/queries/analytics'; import type { OrganizationWithPlans } from '@cio/db/queries/organization/types'; @@ -524,8 +525,13 @@ export async function getOrgSetupData(siteName: string) { */ export async function createOrgPlan(data: TNewOrganizationPlan) { try { + // Subscriptions always attach to the primary workspace, even if a + // secondary org id was supplied at checkout. + const primary = data.orgId ? await getAccountPrimary(data.orgId) : null; + const targetOrgId = primary?.id ?? data.orgId; + const plan = await createOrganizationPlan({ - orgId: data.orgId, + orgId: targetOrgId, planName: data.planName, subscriptionId: data.subscriptionId, triggeredBy: data.triggeredBy, diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts index fc553f6f..d60f6647 100644 --- a/apps/api/src/services/organization/audience.ts +++ b/apps/api/src/services/organization/audience.ts @@ -5,7 +5,8 @@ import type { TImportAudienceMembers } from '@cio/utils/validation/organization'; import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program'; import { createOrganizationInvite, @@ -202,18 +203,19 @@ async function enrollAudienceStudentProfilesInCourses( .map(async (p) => { const email = profileEmailMap.get(p.profileId)!; try { - await sendEmail('studentCourseWelcome', { + await enqueueTransactionalEmail('studentCourseWelcome', { to: email, fields: { orgName: organization.name, courseName: courseTitleByGroupId.get(p.groupId) || 'Course', loginUrl }, - from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), + idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}` }); emailsSent++; } catch (emailError) { - console.error(`enrollAudienceStudentProfilesInCourses email error for ${email}:`, emailError); + console.error(`enrollAudienceStudentProfilesInCourses enqueue error for ${email}:`, emailError); } }); @@ -283,18 +285,19 @@ async function enrollAudienceStudentProfilesInPrograms( const email = profileEmailMap.get(pair.profileId)!; try { - await sendEmail('studentProgramWelcome', { + await enqueueTransactionalEmail('studentProgramWelcome', { to: email, fields: { orgName: organization.name, programName: programNameById.get(pair.programId) || 'Program', loginUrl }, - from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), + idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}` }); emailsSent++; } catch (emailError) { - console.error(`enrollAudienceStudentProfilesInPrograms email error for ${email}:`, emailError); + console.error(`enrollAudienceStudentProfilesInPrograms enqueue error for ${email}:`, emailError); } }) ); @@ -385,7 +388,7 @@ async function createStudentOrgInvitesAndSendEmails(input: { const token = tokenByEmail.get(email)!; try { const inviteLink = buildInviteLink(token); - const responses = await sendEmail('studentOrgInvite', { + await enqueueTransactionalEmail('studentOrgInvite', { to: email, fields: { email, @@ -394,26 +397,16 @@ async function createStudentOrgInvitesAndSendEmails(input: { expiresAt: getExpiryLabel(expiresAt), courseNames: accessNamesLabel }, - from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), + idempotencyKey: `student-org-invite:${invite.id}` }); - const allSuccessful = responses.every((r) => r.success); - if (allSuccessful) { - return { - inviteId: invite.id, - email, - success: true as const, - error: undefined as string | undefined - }; - } + // Optimistic — see comment in services/organization/invite.ts. return { inviteId: invite.id, email, - success: false as const, - error: responses - .filter((r) => !r.success) - .map((r) => r.error || 'Unknown') - .join('; ') + success: true as const, + error: undefined as string | undefined }; } catch (emailError) { const message = emailError instanceof Error ? emailError.message : 'Unknown email error'; @@ -685,7 +678,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB let emailSent = false; try { const inviteLink = buildInviteLink(token); - const responses = await sendEmail('studentOrgInvite', { + await enqueueTransactionalEmail('studentOrgInvite', { to: emailToUse, fields: { email: emailToUse, @@ -694,27 +687,23 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB expiresAt: getExpiryLabel(expiresAt), courseNames: accessNamesLabel }, - from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`) + from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), + idempotencyKey: `student-org-invite:${invite.id}` }); - const allSuccessful = responses.every((r) => r.success); - emailSent = allSuccessful; + + emailSent = true; + // Optimistic EMAIL_SENT — worker handles retries; final failure flips + // email_delivery to `failed` for operator follow-up. await createOrganizationInviteAudits([ { inviteId: invite.id, organizationId: orgId, - eventType: allSuccessful ? 'EMAIL_SENT' : 'EMAIL_FAILED', + eventType: 'EMAIL_SENT', actorProfileId: invitedByProfileId, targetEmail: emailToUse, ipAddress: null, userAgent: null, - metadata: allSuccessful - ? {} - : { - error: responses - .filter((r) => !r.success) - .map((r) => r.error || 'Unknown') - .join('; ') - } + metadata: {} } ]); } catch (emailError) { diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index c7bb97fa..c7e1b49b 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -27,7 +27,7 @@ import { db, type DbOrTxClient } from '@cio/db/drizzle'; import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; -import { sendEmail } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; @@ -244,7 +244,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: const inviteLink = buildInviteLink(token); try { - const responses = await sendEmail('inviteTeacher', { + await enqueueTransactionalEmail('inviteTeacher', { to: email, fields: { email, @@ -253,25 +253,14 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: roleName, expiresAt: getExpiryLabel(expiresAt), inviteLink - } + }, + idempotencyKey: `org-invite-teacher:${invite.id}` }); - const allSuccessful = responses.every((response) => response.success); - if (!allSuccessful) { - const message = responses - .filter((response) => !response.success) - .map((response) => response.error || 'Unknown email error') - .join('; '); - - await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_FAILED', { - actorProfileId: invitedByProfileId, - targetEmail: email, - metadata: { error: message } - }); - - continue; - } - + // Record EMAIL_SENT optimistically — the worker retries up to 5 times, + // and a final failure flips the email_delivery row to `failed` for + // operator follow-up. EMAIL_FAILED here only means the enqueue itself + // failed (DB outbox write or transient validation error). await recordOrganizationInviteAudit(invite.id, orgId, 'EMAIL_SENT', { actorProfileId: invitedByProfileId, targetEmail: email diff --git a/apps/api/src/services/program/goal.ts b/apps/api/src/services/program/goal.ts new file mode 100644 index 00000000..e13dec76 --- /dev/null +++ b/apps/api/src/services/program/goal.ts @@ -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 { + 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 }>> { + 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 { + 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 { + 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 { + return updateGoal(goalId, { status: 'archived' }); +} + +export async function removeGoal(goalId: string): Promise { + 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()), + getProgramGoalAssignments(goalId) + ]); + + const completionByKey = new Map(); + 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 + }; + }) + }; +} diff --git a/apps/api/src/services/program/invite.ts b/apps/api/src/services/program/invite.ts new file mode 100644 index 00000000..67524689 --- /dev/null +++ b/apps/api/src/services/program/invite.ts @@ -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 + }); +} diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index d61e14e4..544b7f0d 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -12,7 +12,8 @@ import type { TSubmissionGradesUpdate, TSubmissionUpdate } from '@cio/utils/validation/submission'; -import { buildEmailFromName, deliverEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueRawEmail } from '@api/services/jobs'; import { createSubmission, deleteSubmission, @@ -977,14 +978,13 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb `; } - await deliverEmail([ - { - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), - to: fullSubmission.groupmember.profile.email, - subject: 'Submission Update', - content - } - ]); + await enqueueRawEmail({ + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + to: fullSubmission.groupmember.profile.email, + subject: 'Submission Update', + content, + idempotencyKey: `submission-update:${submissionId}:${newStatusId}` + }); } /** @@ -1035,18 +1035,14 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s `; - // Send email to all tutors - const emailPromises = tutorsResult.map((tutor) => { - if (!tutor.email) return Promise.resolve(); - return deliverEmail([ - { - from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), - to: tutor.email, - subject: `[Submitted]: ${exercise.title}`, - content - } - ]); - }); + const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email)); + if (tutorEmails.length === 0) return; - await Promise.all(emailPromises); + await enqueueRawEmail({ + from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), + to: tutorEmails, + subject: `[Submitted]: ${exercise.title}`, + content, + idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}` + }); } diff --git a/apps/api/src/services/widget-payload.ts b/apps/api/src/services/widget-payload.ts index f90d1cdf..a8a4431b 100644 --- a/apps/api/src/services/widget-payload.ts +++ b/apps/api/src/services/widget-payload.ts @@ -4,14 +4,11 @@ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@ import { listWidgetCourses } from '@cio/db/queries/widget'; import { PLAN } from '@cio/utils/plans'; import { - ZWidgetPayload, - getLayoutCourseLimit, + buildWidgetPayload as buildWidgetPayloadShared, normalizeWidgetConfig, resolveWidgetPlanGatedFields, - type TWidgetLayoutType, - type TWidgetPayload, - type TWidgetPayloadCategory, - type TWidgetPayloadCourseTag + type BuildWidgetPayloadCourse, + type TWidgetPayload } from '@cio/utils/validation/widget'; import type { TWidget } from '@db/types'; import * as csstree from 'css-tree'; @@ -107,7 +104,7 @@ export function generateWidgetPublicKey(): string { return `wgt_${encodeBase58(bytes).slice(0, 16)}`; } -function sanitizeCustomCssValue(customCss: string, planName?: string | null): string { +export function sanitizeCustomCssValue(customCss: string, planName?: string | null): string { if (!customCss.trim() || !planName || planName === PLAN.BASIC) { return ''; } @@ -146,7 +143,7 @@ function sanitizeCustomCssValue(customCss: string, planName?: string | null): st return csstree.generate(ast); } -function getCourseBaseUrl(siteName: string, customDomain?: string | null): string { +export function getCourseBaseUrl(siteName: string, customDomain?: string | null): string { if (customDomain) { return `https://${customDomain}`; } @@ -174,6 +171,28 @@ export function getWidgetEmbedCode(publicKey: string): string { return `
    \n`; } +type OrgCourse = Awaited>['items'][number]; + +export function formatCourseForWidget( + course: OrgCourse, + tags: BuildWidgetPayloadCourse['tags'] +): BuildWidgetPayloadCourse { + return { + id: course.id, + slug: course.slug ?? course.id, + title: course.title, + description: course.description, + imageUrl: course.bannerImage || course.logo || null, + isPublished: course.isPublished, + price: course.cost ? `${course.currency} ${course.cost}` : 'Free', + lessonCount: course.lessonCount, + exerciseCount: course.exerciseCount ?? 0, + courseType: course.type, + createdAt: course.createdAt ?? new Date().toISOString(), + tags + }; +} + export async function buildWidgetPayload( widget: TWidget, overrideSelectedCourseIds?: string[] @@ -184,216 +203,47 @@ export async function buildWidgetPayload( } const activePlan = await getActiveOrganizationPlan(widget.organizationId); - const normalizedConfig = normalizeWidgetConfig(widget.config as Record, activePlan?.planName); + const planName = activePlan?.planName ?? null; + const normalizedConfig = normalizeWidgetConfig(widget.config as Record, planName); const selectedCourseIds = overrideSelectedCourseIds ?? (await listWidgetCourses(widget.id)).map((course) => course.courseId); - const allCourses = await getOrgCourses({ orgId: widget.organizationId, limit: 200 }); - const allPublishedCourses = allCourses.items.filter((course) => course.isPublished); - const manualOrderLookup = new Map(selectedCourseIds.map((courseId, index) => [courseId, index])); - - let resolvedCourses = - widget.selectionMode === 'manual' - ? allCourses.items.filter((course) => manualOrderLookup.has(course.id)) - : allPublishedCourses; - + const allCoursesResult = await getOrgCourses({ orgId: widget.organizationId, limit: 200 }); const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( widget.organizationId, - resolvedCourses.map((course) => course.id) + allCoursesResult.items.map((course) => course.id) ); - if (normalizedConfig.filters.includeTagIds.length > 0 || normalizedConfig.filters.excludeTagIds.length > 0) { - resolvedCourses = resolvedCourses.filter((course) => { - const courseTags = tagsByCourseId[course.id] ?? []; - const courseTagIds = new Set(courseTags.map((tag) => tag.id)); - const includesRequired = - normalizedConfig.filters.includeTagIds.length === 0 || - normalizedConfig.filters.includeTagIds.some((tagId) => courseTagIds.has(tagId)); - const excludesBlocked = normalizedConfig.filters.excludeTagIds.every((tagId) => !courseTagIds.has(tagId)); + const allCourses = allCoursesResult.items.map((course) => + formatCourseForWidget(course, tagsByCourseId[course.id] ?? []) + ); - 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 layoutOptions = normalizedConfig.layoutOptions; - - // Hoist the configured "main" course to the front for layouts that have a hero slot, - // so the rest of the slice/limit logic can treat slot 0 as the featured course. - if (layoutType === 'primary_course' && layoutOptions.primaryCourse.featuredCourseId) { - const featuredId = layoutOptions.primaryCourse.featuredCourseId; - const featuredIndex = resolvedCourses.findIndex((course) => course.id === featuredId); - if (featuredIndex > 0) { - const [featuredCourse] = resolvedCourses.splice(featuredIndex, 1); - resolvedCourses = [featuredCourse, ...resolvedCourses]; - } - } else if (layoutType === 'editorial_spotlight' && layoutOptions.editorialSpotlight.mainCourseId) { - const mainId = layoutOptions.editorialSpotlight.mainCourseId; - const mainIndex = resolvedCourses.findIndex((course) => course.id === mainId); - if (mainIndex > 0) { - const [mainCourse] = resolvedCourses.splice(mainIndex, 1); - resolvedCourses = [mainCourse, ...resolvedCourses]; - } - } - - // Cap the course list according to the active layout's limit. Category shelf - // builds its own grouped slices below from the already-filtered universe. - if (layoutType !== 'category_shelf') { - const layoutLimit = getLayoutCourseLimit(layoutType, layoutOptions); - resolvedCourses = resolvedCourses.slice(0, Math.max(layoutLimit, 1)); - } else { - resolvedCourses = resolvedCourses.slice(0, getLayoutCourseLimit(layoutType, layoutOptions)); - } - - // Build payload-specific aggregates. - const heroCourseId = - layoutType === 'primary_course' - ? (layoutOptions.primaryCourse.featuredCourseId ?? resolvedCourses[0]?.id ?? null) - : layoutType === 'editorial_spotlight' - ? (layoutOptions.editorialSpotlight.mainCourseId ?? resolvedCourses[0]?.id ?? null) - : null; - - const tagPool = buildTagPool(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId); - const categories = buildCategoriesFromShelf(layoutType, normalizedConfig, resolvedCourses, tagsByCourseId); - - const planGatedFields = resolveWidgetPlanGatedFields(activePlan?.planName, normalizedConfig.themePreset); - const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, activePlan?.planName); + const planGatedFields = resolveWidgetPlanGatedFields(planName, normalizedConfig.themePreset); + const sanitizedCustomCss = sanitizeCustomCssValue(normalizedConfig.advanced.customCss, planName); const orgBaseUrl = getCourseBaseUrl(organization.siteName ?? '', organization.customDomain); - return ZWidgetPayload.parse({ - version: 'v1', - widgetId: widget.id, - publicKey: widget.publicKey, + return buildWidgetPayloadShared({ + widget: { + id: widget.id, + publicKey: widget.publicKey, + layoutType: widget.layoutType, + selectionMode: widget.selectionMode, + config: normalizedConfig + }, organization: { id: organization.id, name: organization.name, - siteName: organization.siteName, + siteName: organization.siteName ?? '', customDomain: organization.customDomain }, - layoutType, - selectionMode: widget.selectionMode, - design: { - ...normalizedConfig, - advanced: { - ...normalizedConfig.advanced, - customCss: sanitizedCustomCss - } - }, + orgBaseUrl, + allCourses, + selectedCourseIds, planGatedFields, - labels: { - loadMoreLabel: normalizedConfig.content.loadMoreLabel, - poweredByLabel: 'Powered by Classroomio' - }, - courses: resolvedCourses.map((course) => ({ - id: course.id, - slug: course.slug ?? course.id, - title: course.title, - description: course.description, - imageUrl: course.bannerImage || course.logo || null, - price: course.cost ? `${course.currency} ${course.cost}` : 'Free', - lessonCount: course.lessonCount, - exerciseCount: course.exerciseCount ?? 0, - rating: null, - ratingCount: 0, - courseType: course.type, - createdAt: course.createdAt ?? new Date().toISOString(), - tags: tagsByCourseId[course.id] ?? [], - featured: heroCourseId ? course.id === heroCourseId : false, - url: `${orgBaseUrl}/course/${course.slug ?? course.id}` - })), - tagPool, - categories, - timestamp: Date.now() + planName, + sanitizedCustomCss }); } -function buildTagPool( - layoutType: TWidgetLayoutType, - config: ReturnType, - courses: Array<{ id: string }>, - tagsByCourseId: Record -): TWidgetPayloadCourseTag[] { - if (layoutType !== 'tag_filter') { - return []; - } - - const allTagsFromCourses = new Map(); - 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, - courses: Array<{ id: string }>, - tagsByCourseId: Record -): 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(); - for (const tags of Object.values(tagsByCourseId)) { - for (const tag of tags) { - tagsById.set(tag.id, tag); - } - } - - const maxPerCategory = config.layoutOptions.categoryShelf.maxPerCategory; - - return orderedCategoryIds - .map((tagId) => { - const tagInfo = tagsById.get(tagId); - if (!tagInfo) { - return null; - } - - const matchingCourses = courses.filter((course) => - (tagsByCourseId[course.id] ?? []).some((tag) => tag.id === tagId) - ); - - return { - tagId, - name: tagInfo.name, - slug: tagInfo.slug, - color: tagInfo.color, - courseIds: matchingCourses.slice(0, maxPerCategory).map((course) => course.id) - } satisfies TWidgetPayloadCategory; - }) - .filter((entry): entry is TWidgetPayloadCategory => entry !== null); -} - export async function listWidgetAvailableEditorData(orgId: string) { const [coursesResult, tagGroups, activePlan] = await Promise.all([ getOrgCourses({ orgId, limit: 200 }), @@ -406,20 +256,9 @@ export async function listWidgetAvailableEditorData(orgId: string) { ); return { - availableCourses: coursesResult.items.map((course) => ({ - id: course.id, - slug: course.slug ?? course.id, - title: course.title, - description: course.description, - imageUrl: course.bannerImage || course.logo || null, - isPublished: course.isPublished, - price: course.cost ? `${course.currency} ${course.cost}` : 'Free', - lessonCount: course.lessonCount, - exerciseCount: course.exerciseCount ?? 0, - courseType: course.type, - createdAt: course.createdAt ?? new Date().toISOString(), - tags: tagsByCourseId[course.id] ?? [] - })), + availableCourses: coursesResult.items.map((course) => + formatCourseForWidget(course, tagsByCourseId[course.id] ?? []) + ), availableTags: tagGroups.flatMap((group) => group.tags.map((tag) => ({ id: tag.id, @@ -429,6 +268,7 @@ export async function listWidgetAvailableEditorData(orgId: string) { groupId: group.id })) ), - planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio') + planGatedFields: resolveWidgetPlanGatedFields(activePlan?.planName, 'classroomio'), + planName: activePlan?.planName ?? null }; } diff --git a/apps/api/src/services/widget.ts b/apps/api/src/services/widget.ts index 6a1f71f8..4993e0f2 100644 --- a/apps/api/src/services/widget.ts +++ b/apps/api/src/services/widget.ts @@ -19,14 +19,15 @@ import { getDefaultWidgetConfig, normalizeWidgetConfig, type TCreateWidget, - type TPreviewWidget, type TRollbackWidget, type TUpdateWidget } from '@cio/utils/validation/widget'; +import { getOrganizationById } from '@cio/db/queries/organization'; import { buildWidgetPayload, generateWidgetPublicKey, + getCourseBaseUrl, getCourseWidgetScriptUrl, getWidgetEmbedCode, listWidgetAvailableEditorData @@ -82,26 +83,37 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); } - const [selectedCourses, versions, editorData, previewPayload] = await Promise.all([ + const organization = await getOrganizationById(orgId); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.WIDGET_NOT_FOUND, 404); + } + + const [selectedCourses, versions, editorData] = await Promise.all([ listWidgetCourses(widget.id), listWidgetVersions(orgId, widget.id), - listWidgetAvailableEditorData(orgId), - buildWidgetPayload(widget) + listWidgetAvailableEditorData(orgId) ]); return { widget: { ...widget, - config: normalizeWidgetConfig(widget.config as Record), + config: normalizeWidgetConfig(widget.config as Record, editorData.planName), selectedCourseIds: selectedCourses.map((course) => course.courseId), embedCode: getWidgetEmbedCode(widget.publicKey), publicScriptUrl: getCourseWidgetScriptUrl() }, + organization: { + id: organization.id, + name: organization.name, + siteName: organization.siteName ?? '', + customDomain: organization.customDomain + }, + orgBaseUrl: getCourseBaseUrl(organization.siteName ?? '', organization.customDomain), availableCourses: editorData.availableCourses, availableTags: editorData.availableTags, - previewPayload, versions, - planGatedFields: editorData.planGatedFields + planGatedFields: editorData.planGatedFields, + planName: editorData.planName }; } catch (error) { if (error instanceof AppError) { @@ -285,34 +297,6 @@ export async function rollbackOrganizationWidget( } } -export async function previewOrganizationWidget(orgId: string, widgetId: string, data: TPreviewWidget) { - try { - const widget = await getWidgetById(orgId, widgetId); - if (!widget) { - throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); - } - - const overriddenWidget = { - ...widget, - layoutType: data.layoutType ?? widget.layoutType, - selectionMode: data.selectionMode ?? widget.selectionMode, - config: data.config ?? widget.config - }; - - return buildWidgetPayload(overriddenWidget, data.selectedCourseIds); - } catch (error) { - if (error instanceof AppError) { - throw error; - } - - throw new AppError( - error instanceof Error ? error.message : 'Failed to build widget preview', - ErrorCodes.WIDGET_FETCH_FAILED, - 500 - ); - } -} - export async function getPublishedWidgetPayload(publicKey: string) { try { const payload = await getPublishedWidgetPayloadByPublicKey(publicKey); diff --git a/apps/api/src/utils/certificate.ts b/apps/api/src/utils/certificate.ts index 79071b01..ef2ab441 100644 --- a/apps/api/src/utils/certificate.ts +++ b/apps/api/src/utils/certificate.ts @@ -1,114 +1,66 @@ -import type { TCertificateDownload } from '@cio/utils/validation/course'; -import { getCloudflarePdfBuffer } from '@api/utils/cloudflare'; +import { + DEFAULT_CERTIFICATE_DESIGN, + renderCertificate, + resolveTemplateId, + type CertificateDesign, + type CertificateRenderData +} from '@cio/certificates'; -export interface CertificateGenerationResult { - success: boolean; - buffer?: Buffer; - error?: string; +import { getCloudflarePdfBuffer, getCloudflarePngBuffer } from '@api/utils/cloudflare'; + +export interface CertificateRenderInput { + design: CertificateDesign; + data: CertificateRenderData; } -export const generateCertificate = async (data: TCertificateDownload) => { - const { html, styles } = generateCertificateHtml(data); - return getCloudflarePdfBuffer(html, styles); -}; +/** + * Coerces a stored `course.certificate` JSONB blob (legacy or current) into a + * complete `CertificateDesign` suitable for `renderCertificate`. + */ +export function resolveCertificateDesign(stored: unknown): CertificateDesign { + const blob = stored && typeof stored === 'object' ? (stored as Record) : {}; + const legacyTheme = typeof blob.theme === 'string' ? (blob.theme as string) : undefined; + const storedDesign = + blob.design && typeof blob.design === 'object' ? (blob.design as Partial) : undefined; -function generateCertificateHtml(data: TCertificateDownload): { html: string; styles: string } { - const { studentName, courseName, courseDescription, orgLogoUrl, orgName, facilitator } = data; + const templateId = resolveTemplateId(storedDesign?.templateId ?? legacyTheme); - const html = ` - - - - Certificate of Completion - - -
    -
    - -

    Certificate of Completion

    -
    -
    -

    ${studentName}

    -

    ${courseName}

    -

    ${courseDescription}

    -
    - -
    - - - `; + const accentColor = + storedDesign?.accentColor && /^#[0-9a-fA-F]{6}$/.test(storedDesign.accentColor) + ? storedDesign.accentColor + : DEFAULT_CERTIFICATE_DESIGN.accentColor; - const styles = ` - body { - font-family: 'Arial', sans-serif; - margin: 0; - padding: 0; - background-color: #f5f5f5; + const storedSignatories = Array.isArray(storedDesign?.signatories) ? storedDesign?.signatories : undefined; + + const signatories: CertificateDesign['signatories'] = [ + { + name: storedSignatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name, + role: storedSignatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role + }, + { + name: storedSignatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name, + role: storedSignatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role } - .certificate { - width: 1123px; - height: 794px; - margin: 0 auto; - background-color: white; - position: relative; - box-shadow: 0 0 10px rgba(0,0,0,0.1); - } - .header { - text-align: center; - padding: 20px; - } - .logo { - max-width: 200px; - margin-bottom: 20px; - } - .title { - font-size: 36px; - color: #333; - margin-bottom: 10px; - } - .content { - text-align: center; - padding: 40px; - } - .student-name { - font-size: 48px; - color: #2c3e50; - margin-bottom: 20px; - } - .course-name { - font-size: 24px; - color: #34495e; - margin-bottom: 10px; - } - .description { - font-size: 18px; - color: #7f8c8d; - margin-bottom: 30px; - } - .footer { - position: absolute; - bottom: 40px; - width: 100%; - text-align: center; - } - .signature { - margin-top: 20px; - } - .date { - margin-top: 10px; - color: #95a5a6; - } - `; - const pdf = { - html: html, - styles: styles + ]; + + return { + templateId, + accentColor, + subtitle: storedDesign?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle, + descriptionOverride: storedDesign?.descriptionOverride, + signatories, + idFormat: storedDesign?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat }; +} - return pdf; +export async function generateCertificatePdf(input: CertificateRenderInput) { + const { html, styles } = renderCertificate(input.design, input.data); + + return getCloudflarePdfBuffer(html, styles); +} + +export async function generateCertificatePng(input: CertificateRenderInput) { + const { html, styles } = renderCertificate(input.design, input.data); + + return getCloudflarePngBuffer(html, styles); } diff --git a/apps/api/src/utils/cloudflare.ts b/apps/api/src/utils/cloudflare.ts index a7fd47d8..7db7e7ad 100644 --- a/apps/api/src/utils/cloudflare.ts +++ b/apps/api/src/utils/cloudflare.ts @@ -26,3 +26,37 @@ export const getCloudflarePdfBuffer = async (html: string, styles?: string) => { throw new Error(error instanceof Error ? error.message : 'Failed to generate PDF'); } }; + +/** + * Renders HTML to a PNG via Cloudflare Browser Rendering's `/screenshot` endpoint. + * The viewport is fixed to 1100x780 to match the certificate canvas. + */ +export const getCloudflarePngBuffer = async (html: string, styles?: string) => { + console.log('Generating PNG with Cloudflare API...'); + try { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE.CONFIGS.ACCOUNT_ID}/browser-rendering/screenshot`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${CLOUDFLARE.CONFIGS.RENDERING_API_KEY}` + }, + body: JSON.stringify({ + html, + addStyleTag: styles ? [{ content: styles }] : undefined, + viewport: { width: 1100, height: 780, deviceScaleFactor: 2 }, + screenshotOptions: { type: 'png', omitBackground: false, fullPage: false } + }) + } + ); + + console.log('PNG response status:', response.status); + const arrayBuffer = await response.arrayBuffer(); + + return Buffer.from(arrayBuffer); + } catch (error) { + console.error('Error generating PNG:', error); + throw new Error(error instanceof Error ? error.message : 'Failed to generate PNG'); + } +}; diff --git a/apps/api/src/utils/course-completion.ts b/apps/api/src/utils/course-completion.ts index 27114f59..8c282b7a 100644 --- a/apps/api/src/utils/course-completion.ts +++ b/apps/api/src/utils/course-completion.ts @@ -5,7 +5,8 @@ import { } from '@cio/db/queries/course/course'; import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; -import { buildEmailFromName, sendEmail } from '@cio/email'; +import { buildEmailFromName } from '@cio/email'; +import { enqueueTransactionalEmail } from '@api/services/jobs'; export function calcCourseProgressPercent(params: { lessonsCompleted: number; @@ -69,7 +70,7 @@ export function scheduleCertificationCompletionWork(params: { const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined); const certificateUrl = `${base}/courses/${courseId}/certificates`; - await sendEmail('studentCourseCompletion', { + await enqueueTransactionalEmail('studentCourseCompletion', { to: studentEmail, fields: { orgName: courseRow.orgName, @@ -78,7 +79,8 @@ export function scheduleCertificationCompletionWork(params: { certificateUrl, customMessage: courseRow.certificate?.emailMessage ?? null }, - from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`) + from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`), + idempotencyKey: `course-completion:${groupMemberId}` }); await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString()); diff --git a/apps/api/src/utils/redis/key-generators.ts b/apps/api/src/utils/redis/key-generators.ts index deae1766..4a5dc621 100644 --- a/apps/api/src/utils/redis/key-generators.ts +++ b/apps/api/src/utils/redis/key-generators.ts @@ -132,3 +132,15 @@ export const agentUploadKeyGenerator = (c: Context): string => { const baseKey = userKeyGenerator(c); return `${baseKey}:agent:upload`; }; + +/** + * Redis keys for engagement analytics read endpoints (landing-stats, funnel, + * country breakdown, time-to-enrollment). Value: JSON. + */ +export function dashAnalyticsKey(route: string, orgId: string, days: number, extra?: string): string { + const suffix = extra ? `:${extra}` : ''; + return `dash:analytics:${route}:${orgId}:${days}${suffix}`; +} + +/** TTL for engagement analytics caches (10 min). */ +export const DASH_ANALYTICS_TTL_SECONDS = 600; diff --git a/apps/api/src/utils/s3.ts b/apps/api/src/utils/s3.ts index 172825d5..9b167c0d 100644 --- a/apps/api/src/utils/s3.ts +++ b/apps/api/src/utils/s3.ts @@ -156,6 +156,22 @@ export async function generateUploadPresignedUrl( return presignedUrl; } +/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */ +export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600; + +export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise { + const client = getPresignS3Client(); + + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: key + }) as GetSignedUrlParameters[1]; + + return getSignedUrl(client as GetSignedUrlParameters[0], command, { + expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS + }); +} + /** * Generates presigned URLs for video download * @param keys Array of video keys diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7148e072..27eed244 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -25,7 +25,11 @@ "@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"], "@cio/utils/*": ["./node_modules/@cio/utils/dist/*"], "@cio/email/*": ["./node_modules/@cio/email/dist/*"], - "@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"] + "@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"], + "@cio/jobs": ["./node_modules/@cio/jobs/dist/index.d.ts"], + "@cio/jobs/*": ["./node_modules/@cio/jobs/dist/*"], + "@cio/analytics": ["./node_modules/@cio/analytics/dist/index.d.ts"], + "@cio/analytics/*": ["./node_modules/@cio/analytics/dist/*"] } }, "include": ["src/**/*"], @@ -33,7 +37,9 @@ "references": [ { "path": "../../packages/db" }, { "path": "../../packages/utils" }, - { "path": "../../packages/email" } + { "path": "../../packages/email" }, + { "path": "../../packages/jobs" }, + { "path": "../../packages/analytics" } ], "tsc-alias": { "resolveFullPaths": true, diff --git a/apps/api/vitest.config.js b/apps/api/vitest.config.js new file mode 100644 index 00000000..d65efadb --- /dev/null +++ b/apps/api/vitest.config.js @@ -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/'] + } + } +}); diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 2ace64b3..a9d76898 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -21,7 +21,10 @@ "dependencies": { "@ai-sdk/svelte": "^4.0.158", "@better-auth/sso": "1.4.18", + "@cio/ai-assistant": "workspace:*", + "@cio/analytics": "workspace:*", "@cio/api": "workspace:*", + "@cio/certificates": "workspace:*", "@cio/db": "workspace:*", "@cio/question-types": "workspace:*", "@cio/ui": "workspace:*", diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index c18bfd5e..dda9122e 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -114,11 +114,11 @@ html .dark { .prose p, .prose li, .prose blockquote { - @apply leading-7; + @apply leading-relaxed; } .prose p { - @apply my-0; + @apply mb-4; } .prose a { @@ -236,7 +236,7 @@ body { iframe.iframe { width: 100%; - max-width: 800px; + max-width: 48rem; margin: 0 auto; height: 450px; border: 1px solid gray; diff --git a/apps/dashboard/src/hooks.server.ts b/apps/dashboard/src/hooks.server.ts index 96781e82..7e505fcc 100644 --- a/apps/dashboard/src/hooks.server.ts +++ b/apps/dashboard/src/hooks.server.ts @@ -5,6 +5,21 @@ import { type Handle, redirect } from '@sveltejs/kit'; import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { ROUTE } from '$lib/utils/constants/routes'; +const ANALYTICS_SESSION_COOKIE = 'cio_aid'; +const ANALYTICS_SESSION_MAX_AGE = 60 * 60 * 24 * 90; + +function ensureAnalyticsSessionCookie(cookies: Parameters[0]['event']['cookies']) { + if (cookies.get(ANALYTICS_SESSION_COOKIE)) return; + + cookies.set(ANALYTICS_SESSION_COOKIE, crypto.randomUUID(), { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: true, + maxAge: ANALYTICS_SESSION_MAX_AGE + }); +} + export const handle: Handle = async (args) => { const { event } = args; const sessionData = await getSessionData(event.cookies); @@ -13,6 +28,10 @@ export const handle: Handle = async (args) => { event.locals = sessionData; } + if (!event.url.pathname.includes('/api')) { + ensureAnalyticsSessionCookie(event.cookies); + } + let response: Response; if (event.url.pathname.includes('/api')) { diff --git a/apps/dashboard/src/lib/features/account/api/account.svelte.ts b/apps/dashboard/src/lib/features/account/api/account.svelte.ts new file mode 100644 index 00000000..ce0c8a00 --- /dev/null +++ b/apps/dashboard/src/lib/features/account/api/account.svelte.ts @@ -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([]); + limits = $state(null); + usage = $state(null); + + async listWorkspaces() { + return this.execute({ + 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({ + 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({ + 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({ + 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(); diff --git a/apps/dashboard/src/lib/features/account/index.ts b/apps/dashboard/src/lib/features/account/index.ts new file mode 100644 index 00000000..6b017338 --- /dev/null +++ b/apps/dashboard/src/lib/features/account/index.ts @@ -0,0 +1,2 @@ +export { default as WorkspacesPage } from './pages/workspaces.svelte'; +export { accountApi } from './api/account.svelte'; diff --git a/apps/dashboard/src/lib/features/account/pages/workspaces.svelte b/apps/dashboard/src/lib/features/account/pages/workspaces.svelte new file mode 100644 index 00000000..8fae89d9 --- /dev/null +++ b/apps/dashboard/src/lib/features/account/pages/workspaces.svelte @@ -0,0 +1,190 @@ + + +{#if !$isPrimaryWorkspace} +
    + {$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })} +
    +{:else} + + {#if usage} + +
    +

    + {$t('account.workspaces.usage_banner', { + learners: usage.learnerCount, + tokens: usage.tokensUsedThisMonth, + workspaces: usage.workspaceCount + })} +

    +
    +
    + + {/if} + + +
    + {$t('account.workspaces.page_title')} + + {#if ctaState.kind === 'create'} + + {:else if ctaState.kind === 'limit'} + + {:else if ctaState.kind === 'upgrade'} + + {/if} +
    + + + {#if accountApi.isLoading && workspaces.length === 0} + + {:else} + {#each workspaces as workspace} + +
    +
    +

    {workspace.name}

    + {workspace.siteName} + {#if workspace.isPrimary} + {$t('account.workspaces.primary_badge')} + {/if} + {#if workspace.readOnlyUntil} + {$t('account.workspaces.read_only_badge')} + {/if} +
    + + {#if !workspace.isPrimary} + + {/if} +
    +
    + {/each} + {/if} +
    +
    +
    +{/if} + + + + + {$t('account.workspaces.create_modal_title')} + + + + + {$t('account.workspaces.name_label')} + + {#if accountApi.errors.name} + {accountApi.errors.name} + {/if} + + + + {$t('account.workspaces.site_name_label')} + + {#if accountApi.errors.siteName} + {accountApi.errors.siteName} + {/if} + + + + + + + + + + + !open && (confirmDeleteId = null)}> + + + {$t('account.workspaces.delete_confirm_title')} + +

    {$t('account.workspaces.delete_confirm_body')}

    + + + + +
    +
    diff --git a/apps/dashboard/src/lib/features/account/utils/types.ts b/apps/dashboard/src/lib/features/account/utils/types.ts new file mode 100644 index 00000000..cbde0188 --- /dev/null +++ b/apps/dashboard/src/lib/features/account/utils/types.ts @@ -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, { success: true }>; +type UsageSuccess = Extract, { success: true }>; + +export type AccountWorkspaceList = ListSuccess['data']['workspaces']; +export type AccountWorkspaceLimits = ListSuccess['data']['limits']; +export type AccountWorkspace = AccountWorkspaceList[number]; +export type AccountUsage = UsageSuccess['data']; diff --git a/apps/dashboard/src/lib/features/agent/api/text-generation.svelte.ts b/apps/dashboard/src/lib/features/agent/api/text-generation.svelte.ts new file mode 100644 index 00000000..3162d590 --- /dev/null +++ b/apps/dashboard/src/lib/features/agent/api/text-generation.svelte.ts @@ -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 { + let generatedText: string | null = null; + + await this.execute({ + 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(); diff --git a/apps/dashboard/src/lib/features/agent/components/ai-generate-button.svelte b/apps/dashboard/src/lib/features/agent/components/ai-generate-button.svelte new file mode 100644 index 00000000..7306ba38 --- /dev/null +++ b/apps/dashboard/src/lib/features/agent/components/ai-generate-button.svelte @@ -0,0 +1,22 @@ + + + { + const text = await textGenerationApi.generate(prompt, tone, format, context, courseId); + if (text) onInsert(text); + }} +/> diff --git a/apps/dashboard/src/lib/features/agent/utils/types.ts b/apps/dashboard/src/lib/features/agent/utils/types.ts new file mode 100644 index 00000000..debde4df --- /dev/null +++ b/apps/dashboard/src/lib/features/agent/utils/types.ts @@ -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, { success: true }>; diff --git a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte index d1546076..3c35ce6b 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte @@ -1,12 +1,14 @@ -{#snippet chatPanelContent()} +
    -{/snippet} - -{#if $isAiAssistantOpen} -
    - - -
    - - - {@render chatPanelContent()} -
    -
    -{/if} - - +
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts index f821044a..859b5622 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts @@ -4,7 +4,9 @@ import type { AgentConversationCreateData, AgentConversationSummary, AgentStatusData, - AiAssistantMessage + AiAssistantMessage, + CompactConversationRequest, + CompactConversationSuccess } from '../utils/types'; class AiAssistantApi extends BaseApiWithErrors { @@ -183,6 +185,71 @@ class AiAssistantApi extends BaseApiWithErrors { return generatedTitle; } + + async renameConversation(conversationId: string, title: string): Promise { + 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 { + 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 { + let compacted: CompactConversationSuccess['data']['messages'] | null = null; + + await this.execute({ + 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(); diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte index e9bf2127..53c7d60b 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-header.svelte @@ -1,5 +1,7 @@
    @@ -59,7 +175,32 @@

    {$t('course.navItems.nav_ai_assistant')}

    {#if conversationTitle && conversationTitle !== 'New conversation'} -

    {conversationTitle}

    + {#if editingConversationTitle} +
    + + {#if renameConversationError} +

    {renameConversationError}

    + {/if} +
    + {:else} + + {/if} {/if}
    @@ -103,7 +244,28 @@ - {#if tokenUsage && tokenUsage.used + tokenUsage.remaining > 0} + {#if isStudent && studentMessageUsage} + {@const usagePercent = Math.min(100, Math.round((studentMessageUsage.used / studentMessageUsage.cap) * 100))} +
    +
    + {studentMessageUsage.used.toLocaleString()} / {studentMessageUsage.cap.toLocaleString()} + {$t('ai_assistant.messages_used_label')} + {usagePercent}% +
    +
    +
    +
    +
    + {:else if !isStudent && tokenUsage && tokenUsage.used + tokenUsage.remaining > 0} {@const totalBudget = tokenUsage.used + tokenUsage.remaining} {@const usagePercent = Math.min(100, Math.round((tokenUsage.used / totalBudget) * 100))}
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte index cc79ab90..662c359e 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte @@ -1,5 +1,8 @@ -
    - {#if isExhausted} -
    - {$t('ai_assistant.tokens_exhausted')} - {#if $currentOrgPath !== '#'} - - {/if} +{#if tutorBlocked} +
    +
    +

    {$t('aiTutor.takeABreak.title')}

    +

    {tutorBlockedMessage(tutorBlocked)}

    - {:else} - {#if isStreaming} +
    +{:else if contextUsage?.isFull && !isStreaming} + +{:else} +
    + {#if isExhausted}
    - {$t('ai_assistant.agent_running_warning')} -
    - {/if} - - {#if error} -
    - {error.message} -
    - {/if} - - {#if isUploading} -
    - - {$t('ai_assistant.uploading_document')} -
    - {:else if uploadedDocument} -
    - - {uploadedDocument.name} - -
    - {/if} - -
    - - {#snippet icon({ item })} - {#if item.type === 'EXERCISE'} - - {:else if item.type === 'SECTION'} - - {:else} - - {/if} - {/snippet} - - {#snippet actions()} - + {/if} +
    + {:else} + {#if isStreaming && !agentRunningWarningDismissed && !isStudent} +
    + {$t('ai_assistant.agent_running_warning')} + + + +
    + {/if} + + {#if displayErrorMessage} +
    + {displayErrorMessage} +
    + {/if} + + {#if isUploading} +
    + + {$t('ai_assistant.uploading_document')} +
    + {:else if uploadedDocument} +
    + + {uploadedDocument.name} + +
    + {/if} -
    +
    + + {#snippet icon({ item })} + {#if item.type === 'EXERCISE'} + + {:else if item.type === 'SECTION'} + + {:else} + + {/if} + {/snippet} - {#if isStreaming} - - {:else} - - {/if} - {/snippet} - -
    - {/if} -
    + {#snippet actions()} + {#if !isStudent} + + {/if} + +
    + + {#if contextUsage && contextUsage.usedTokens > 0} + + {/if} + + {#if isStreaming} + + {:else if !isStudent} + + {:else} + + {/if} + {/snippet} + +
    + {/if} +
    +{/if} diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte index b96e0e29..d040a8ae 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte @@ -5,26 +5,40 @@ import MessageBubble from '$features/ai-assistant/message-bubble.svelte'; import ProgressCard from '$features/ai-assistant/progress-card.svelte'; import { t } from '$lib/utils/functions/translations'; - import type { ProgressStep } from '$features/ai-assistant/utils/tool-labels'; - import type { AiAssistantMessage } from '$features/ai-assistant/utils/types'; + import type { ProgressStep, ToolLineUi } from '$features/ai-assistant/utils/tool-labels'; + import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types'; + import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant'; + + interface QuickActionOption { + key: string; + prompt: string; + } interface PlanExecutionState { - title: string; + titleKey: string; steps: ProgressStep[]; - currentAction?: string; + currentActionLine?: ToolLineUi; isStopped: boolean; + /** True while the assistant is invoking course-mutation tools (see `MUTATION_TOOLS`). */ + hasMutations?: boolean; } interface Props { messages: AiAssistantMessage[]; - status: string; isStreaming: boolean; + isStudent: boolean; courseId: string; planExecutionState: PlanExecutionState | null; - quickActions: string[]; + quickActions: QuickActionOption[]; onQuickAction: (action: string) => void; onImplementPlan: (editedPlan: unknown) => void; - onAskPlanChanges: () => void; + onAskPlanChanges: (message: string) => void; + onSubmitTemplateAnswers: (payload: { + templateId: CourseTemplateId; + answers: Record; + fields: TemplateFormField[]; + }) => void; + onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void; onStop: () => void; onResume: () => void; onMentionClick: (route: string) => void; @@ -32,14 +46,16 @@ let { messages, - status, isStreaming, + isStudent, courseId, planExecutionState, quickActions, onQuickAction, onImplementPlan, onAskPlanChanges, + onSubmitTemplateAnswers, + onSkipTemplateForm, onStop, onResume, onMentionClick @@ -48,13 +64,34 @@ let messagesContainer: HTMLDivElement | undefined = $state(); let lastMessageCount = $state(0); let lastStepsCount = $state(0); - let lastCurrentAction = $state(''); + let lastCurrentActionSig = $state(''); + let lastStreamingSig = $state(0); const isEmpty = $derived(messages.length === 0); - function scrollToBottom() { + // Total text length of the most recent assistant message — grows as tokens stream in. + // Used to keep the view pinned to the latest text while the AI is typing. + const streamingContentSig = $derived.by(() => { + if (messages.length === 0) return 0; + + const last = messages[messages.length - 1]; + + if (last.role !== 'assistant') return 0; + + let total = 0; + + for (const part of last.parts ?? []) { + if ((part as { type?: string }).type === 'text') { + total += ((part as { text?: string }).text ?? '').length; + } + } + + return total; + }); + + function scrollToBottom(behavior: ScrollBehavior = 'smooth') { requestAnimationFrame(() => { - messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); + messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior }); }); } @@ -67,6 +104,16 @@ scrollToBottom(); }); + $effect(() => { + if (!messagesContainer) return; + + if (streamingContentSig === lastStreamingSig) return; + + lastStreamingSig = streamingContentSig; + // Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor. + scrollToBottom('auto'); + }); + $effect(() => { if (!messagesContainer || !planExecutionState) return; @@ -77,9 +124,10 @@ return; } - const action = planExecutionState.currentAction ?? ''; - if (action !== lastCurrentAction) { - lastCurrentAction = action; + const actionSig = planExecutionState.currentActionLine ? JSON.stringify(planExecutionState.currentActionLine) : ''; + + if (actionSig !== lastCurrentActionSig) { + lastCurrentActionSig = actionSig; scrollToBottom(); } }); @@ -92,17 +140,17 @@

    - {$t('ai_assistant.empty_state')} + {$t(isStudent ? 'ai_assistant.student_empty_state' : 'ai_assistant.empty_state')}

    - {#each quickActions as action (action)} + {#each quickActions as option (option.key)} {/each}
    @@ -110,11 +158,31 @@ {:else}
    - {#each messages as message (message.id)} - + {#each messages as message, messageIndex (message.id)} + {@const compaction = (message.metadata as AiAssistantMessageMetadata | undefined)?.compaction} + {@const isLast = messageIndex === messages.length - 1} + + {#if compaction} +

    + {$t('ai_assistant.context_compacted_badge', { count: compaction.originalMessageCount })} +

    + {/if} + + {/each} - {#if status === 'submitted' && isStreaming} + {#if isStreaming && (isStudent || !planExecutionState)}
    @@ -122,12 +190,14 @@
    {/if} - {#if planExecutionState} + {#if planExecutionState && !isStudent}
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/context-full-state.svelte b/apps/dashboard/src/lib/features/ai-assistant/context-full-state.svelte new file mode 100644 index 00000000..d1403ac5 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/context-full-state.svelte @@ -0,0 +1,66 @@ + + +
    +
    +

    + {$t('ai_assistant.context_full_title')} +

    +

    + {$t('ai_assistant.context_full_description')} +

    + +
    + + + +
    +
    +
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/context-indicator.svelte b/apps/dashboard/src/lib/features/ai-assistant/context-indicator.svelte new file mode 100644 index 00000000..d0dfcbc0 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-assistant/context-indicator.svelte @@ -0,0 +1,46 @@ + + + + + + + + +

    {tooltipText}

    +
    +
    +
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/index.ts b/apps/dashboard/src/lib/features/ai-assistant/index.ts index 76a02b52..e0bb9d1c 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/index.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/index.ts @@ -1,4 +1,14 @@ export { default as AiCourseChat } from './ai-course-chat.svelte'; export { default as PlanView } from './plan-view.svelte'; export { default as ProgressCard } from './progress-card.svelte'; -export { isAiAssistantOpen, openAiAssistant, closeAiAssistant, toggleAiAssistant } from './utils/store'; +export { + AI_ASSISTANT_PANEL_ID, + chatDraft, + clearChatDraft, + openAiAssistant, + closeAiAssistant, + quoteInChat, + setChatDraft, + toggleAiAssistant +} from './utils/store'; +export { aiAssistantPanelDefinition } from './panel'; diff --git a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte index 5cd27701..a3dbfd2c 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte @@ -8,26 +8,64 @@ import { renderMarkdown } from '$features/ai-assistant/utils/markdown'; import { renderMentions } from '$features/ai-assistant/utils/mentions'; import PlanView from '$features/ai-assistant/plan-view.svelte'; - import { getToolLabel, getToolResultLabel } from '$features/ai-assistant/utils/tool-labels'; + import TemplateFormCard from '$features/ai-assistant/template-form-card.svelte'; + import { isTemplateFormResolved } from '$features/ai-assistant/utils/template-form-resolved'; + import { mergeTemplateFieldsWithRegistry } from '$features/ai-assistant/utils/template-fields'; + import ToolLine from '$features/ai-assistant/utils/tool-line.svelte'; + import { + getCompletedToolLine, + getPendingToolI18nKey, + getPendingToolI18nVars, + getPendingToolLine + } from '$features/ai-assistant/utils/tool-labels'; import type { CoursePlan } from '$features/ai-assistant/utils/course-plan'; import { t } from '$lib/utils/functions/translations'; import { getAgentToolName, + getAgentToolInput, getAgentToolResult, getAgentToolStatus, isAgentToolPart } from '$features/ai-assistant/utils/tool-parts'; import type { AiAssistantMessage, AiAssistantMessageMetadata } from '$features/ai-assistant/utils/types'; + import type { CourseTemplateId, TemplateFormField } from '@cio/ai-assistant'; interface Props { message: AiAssistantMessage; + messages: AiAssistantMessage[]; courseId: string; onImplementPlan: (editedPlan: unknown) => void; - onAskPlanChanges: () => void; + onAskPlanChanges: (message: string) => void; + isStreaming: boolean; + isLast?: boolean; + onSubmitTemplateAnswers: (payload: { + templateId: CourseTemplateId; + answers: Record; + fields: TemplateFormField[]; + }) => void; + onSkipTemplateForm: (payload: { templateId: CourseTemplateId }) => void; onMentionClick: (route: string) => void; } - let { message, courseId, onImplementPlan, onAskPlanChanges, onMentionClick }: Props = $props(); + let { + message, + messages, + courseId, + onImplementPlan, + onAskPlanChanges, + onSubmitTemplateAnswers, + onSkipTemplateForm, + onMentionClick, + isStreaming, + isLast = false + }: Props = $props(); + + function localizePendingTool(toolName: string): string { + const vars = getPendingToolI18nVars(toolName); + const key = getPendingToolI18nKey(toolName); + + return t.get(key, vars ?? {}); + } function handleBubbleClick(event: MouseEvent) { const target = event.target as HTMLElement; @@ -46,12 +84,15 @@ const messageAttachment = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.attachment); const tokenUsage = $derived((message.metadata as AiAssistantMessageMetadata | undefined)?.tokenUsage); + /** Tool parts rendered in a second pass so narrative text always appears above them (stream order often emits tools first). */ function isDeferredPlanPart(part: Record) { if (!isAgentToolPart(part)) { return false; } - return getAgentToolName(part) === 'generate_course_plan'; + const name = getAgentToolName(part); + + return name === 'generate_course_plan' || name === 'ask_template_questions'; } function truncateErrorText(errorText: string): string { @@ -72,61 +113,104 @@ return (part as { errorText?: string }).errorText; } - const inlineParts = $derived(message.parts.filter((part) => !isDeferredPlanPart(part as Record))); + const inlineParts = $derived(message.parts.filter((part) => (part as { type?: string }).type === 'text')); const deferredPlanParts = $derived( message.parts.filter((part) => isDeferredPlanPart(part as Record)) ); + const hasBubbleContent = $derived(inlineParts.length > 0 || deferredPlanParts.length > 0 || !!messageAttachment); + const showStreamingSpinner = $derived(message.role === 'assistant' && !hasBubbleContent && isStreaming && isLast);
    - -
    - {#if messageAttachment} -
    - - {messageAttachment.name} -
    - {/if} - - {#each inlineParts as part, partIndex (partIndex)} - {#if part.type === 'text'} + {#if message.role === 'assistant' && !hasBubbleContent && !showStreamingSpinner} + + {:else} + +
    + {#if showStreamingSpinner} + + {/if} + {#if messageAttachment}
    - - {@html renderMentions(renderMarkdown(part.text as string), courseId)} + + {messageAttachment.name}
    - {:else if isAgentToolPart(part)} + {/if} + + {#each inlineParts as part, partIndex (partIndex)} + {#if part.type === 'text'} +
    + + {@html renderMentions(renderMarkdown(part.text as string), courseId)} +
    + {/if} + {/each} + + {#each deferredPlanParts as part, partIndex (partIndex)} {@const toolName = getAgentToolName(part)} {@const toolResult = getAgentToolResult(part)} {@const toolStatus = getAgentToolStatus(part)} {@const errorText = getPartErrorText(part)} {#if toolName === 'generate_course_plan' && toolStatus === 'completed'} - +
    + +
    + {:else if toolName === 'ask_template_questions' && (toolStatus === 'completed' || toolStatus === 'in_progress')} + {@const merged = (toolResult ?? getAgentToolInput(part)) as + | { templateId?: CourseTemplateId; fields?: TemplateFormField[] } + | undefined} + {#if merged?.templateId} + {@const canonicalFields = mergeTemplateFieldsWithRegistry(merged.templateId, merged.fields)} + {#if canonicalFields.length > 0} +
    + +
    + {/if} + {/if} {:else} -
    +
    {#if toolStatus === 'completed' && toolName} - {getToolResultLabel(toolName, toolResult)} + {:else if toolStatus === 'failed' && toolName} - {errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`} + {#if errorText} + {truncateErrorText(errorText)} + {:else} + {$t('ai_assistant.run_failed_after', { action: localizePendingTool(toolName) })} + {/if} {#if errorText}
    {/if} - {/if} - {/each} + {/each} - {#each deferredPlanParts as part, partIndex (partIndex)} - {@const toolName = getAgentToolName(part)} - {@const toolResult = getAgentToolResult(part)} - {@const toolStatus = getAgentToolStatus(part)} - {@const errorText = getPartErrorText(part)} - {#if toolName === 'generate_course_plan' && toolStatus === 'completed'} -
    - -
    - {:else} -
    - {#if toolStatus === 'completed' && toolName} - - {getToolResultLabel(toolName, toolResult)} - {:else if toolStatus === 'failed' && toolName} - - - {errorText ? truncateErrorText(errorText) : `${getToolLabel(toolName)} failed`} - - {#if errorText} - - {/if} - {:else if toolStatus === 'in_progress' && toolName} - - {getToolLabel(toolName)} - {:else} - - {toolName ? getToolLabel(toolName) : 'Working'} - {/if} + {#if message.role === 'assistant' && tokenUsage} +
    + + {tokenUsage.totalTokens.toLocaleString()} + {$t('ai_assistant.tokens_label')} +
    {/if} - {/each} - - {#if message.role === 'assistant' && tokenUsage} -
    - - {tokenUsage.totalTokens.toLocaleString()} - {$t('ai_assistant.tokens_label')} - -
    - {/if} -
    +
    + {/if}
    diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts index 5b002a63..ac59031c 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/tool-parts.ts @@ -46,6 +46,10 @@ export function getAgentToolResult(part: AgentToolPart): unknown { return part.output; } +export function getAgentToolInput(part: AgentToolPart): unknown { + return (part as { input?: unknown }).input; +} + export function getAgentToolStatus(part: AgentToolPart): ProgressStep['status'] { if (part.state === 'result' || part.state === 'output-available') { return 'completed'; diff --git a/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts b/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts index fc3de202..3321ae8e 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/utils/types.ts @@ -1,5 +1,6 @@ import type { UIMessage } from 'ai'; import { classroomio, type InferResponseType } from '$lib/utils/services/api'; +import type { CourseTemplateId } from '@cio/ai-assistant'; export interface UploadedDocument { id: string; @@ -23,10 +24,22 @@ export interface AiAssistantMessageContinuation { finishReason?: string; } +export type AiAssistantTemplateMetadata = + | { id: CourseTemplateId } + | { action: 'submit_template_answers'; templateId: CourseTemplateId; answers: Record } + | { action: 'skip_template_form'; templateId: CourseTemplateId }; + +export interface AiAssistantCompactionMetadata { + compactedAt: string; + originalMessageCount: number; +} + export interface AiAssistantMessageMetadata { attachment?: AiAssistantMessageAttachment; tokenUsage?: AiAssistantMessageTokenUsage; continuation?: AiAssistantMessageContinuation; + template?: AiAssistantTemplateMetadata; + compaction?: AiAssistantCompactionMetadata; } export type AiAssistantMessage = UIMessage; @@ -60,8 +73,13 @@ export type AgentConversationCreateData = AgentConversationCreateSuccess['data'] export type AgentHistorySaveRequest = (typeof classroomio.agent.history)[':conversationId']['$put']; +export type AgentHistoryRenameRequest = (typeof classroomio.agent.history)[':conversationId']['$patch']; + export type AgentHistoryDeleteRequest = (typeof classroomio.agent.history)[':conversationId']['$delete']; +export type CompactConversationRequest = (typeof classroomio.agent.history)[':conversationId']['compact']['$post']; +export type CompactConversationSuccess = Extract, { success: true }>; + export type GenerateCourseTitleRequest = (typeof classroomio.agent)['generate-course-title']['$post']; export type GenerateCourseTitleSuccess = Extract, { success: true }>; export type GenerateCourseTitleData = GenerateCourseTitleSuccess['data']; diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts b/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts new file mode 100644 index 00000000..f2994a9f --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts @@ -0,0 +1,116 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { ZAiTutorSettingsUpdate, ZCourseAiTutorOverride } from '@cio/utils/validation/agent'; +import { snackbar } from '$features/ui/snackbar/store'; + +import type { + CourseAiTutorOverride, + CourseAiTutorView, + GetCourseAiTutorRequest, + GetOrgAiTutorRequest, + OrgAiTutorSettings, + UpdateCourseAiTutorRequest, + UpdateOrgAiTutorRequest +} from '../utils/types'; + +class AiTutorApi extends BaseApiWithErrors { + orgSettings = $state(null); + courseSettings = $state(null); + loading = $state(false); + saving = $state(false); + + async fetchOrgSettings() { + this.loading = true; + + try { + await this.execute({ + requestFn: () => classroomio.organization['ai-tutor'].$get(), + logContext: 'fetching org AI tutor settings', + onSuccess: (response) => { + this.orgSettings = response.data; + } + }); + } finally { + this.loading = false; + } + } + + async updateOrgSettings(patch: Partial) { + const result = ZAiTutorSettingsUpdate.safeParse(patch); + + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + this.saving = true; + + try { + await this.execute({ + requestFn: () => classroomio.organization['ai-tutor'].$put({ json: result.data }), + logContext: 'updating org AI tutor settings', + onSuccess: (response) => { + this.orgSettings = response.data; + this.errors = {}; + snackbar.success('aiTutor.snackbar.saved'); + }, + onError: (result) => { + if (typeof result !== 'string' && 'field' in result && result.field) { + this.errors[result.field] = result.error; + } + } + }); + } finally { + this.saving = false; + } + } + + async fetchCourseSettings(courseId: string) { + this.loading = true; + + try { + await this.execute({ + requestFn: () => classroomio.course[':courseId']['ai-tutor'].$get({ param: { courseId } }), + logContext: 'fetching course AI tutor settings', + onSuccess: (response) => { + this.courseSettings = response.data; + } + }); + } finally { + this.loading = false; + } + } + + async updateCourseSettings(courseId: string, patch: CourseAiTutorOverride) { + const result = ZCourseAiTutorOverride.safeParse(patch ?? {}); + + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + this.saving = true; + + try { + await this.execute({ + requestFn: () => + classroomio.course[':courseId']['ai-tutor'].$put({ + param: { courseId }, + json: result.data + }), + logContext: 'updating course AI tutor settings', + onSuccess: (response) => { + if (this.courseSettings) { + this.courseSettings = { ...this.courseSettings, override: response.data }; + } + this.errors = {}; + snackbar.success('aiTutor.snackbar.saved'); + } + }); + } finally { + this.saving = false; + } + } +} + +export const aiTutorApi = new AiTutorApi(); diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/components/tutor-settings-form.svelte b/apps/dashboard/src/lib/features/ai-tutor-settings/components/tutor-settings-form.svelte new file mode 100644 index 00000000..359950c5 --- /dev/null +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/components/tutor-settings-form.svelte @@ -0,0 +1,295 @@ + + + + + {$t('aiTutor.section.availability')} + {$t('aiTutor.section.availability_description')} + + + + {$t('aiTutor.field.enabled')} + + + + + + + {$t('aiTutor.section.personality')} + {$t('aiTutor.section.personality_description')} + + + + {$t('aiTutor.field.persona')} + + {$t( + personaOptions.find((o) => o.value === $store.persona)?.labelKey ?? 'aiTutor.persona.encouraging' + )} + + {#each personaOptions as opt (opt.value)} + {$t(opt.labelKey)} + {/each} + + + + + {#if $store.persona === 'custom'} + + {$t('aiTutor.field.customPersona')} +