feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import {
|
||||
getCourseProgress as getCourseProgressQuery,
|
||||
type TCourseCertificationRow
|
||||
@@ -67,7 +67,11 @@ export function scheduleCertificationCompletionWork(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
|
||||
const base = getDashboardBaseUrl({
|
||||
siteName: courseRow.orgSiteName,
|
||||
customDomain: courseRow.orgCustomDomain,
|
||||
isCustomDomainVerified: courseRow.orgIsCustomDomainVerified
|
||||
});
|
||||
const certificateUrl = `${base}/courses/${courseId}/certificates`;
|
||||
|
||||
await enqueueTransactionalEmail('studentCourseCompletion', {
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import type { Context } from 'hono';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
|
||||
import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
|
||||
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
|
||||
|
||||
export { ErrorCodes, type ErrorCode };
|
||||
|
||||
/**
|
||||
* Standard application error with code for easy identification
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
error: string | Error,
|
||||
public code: string,
|
||||
public statusCode: ContentfulStatusCode = 500,
|
||||
public field?: string
|
||||
) {
|
||||
super(error instanceof Error ? error.message : error);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
// AppError moved to `@cio/utils/errors` so it can be thrown from
|
||||
// non-api workspace packages (notably `@cio/core`). Re-exported
|
||||
// here to keep the long-standing `@api/utils/errors` import path working
|
||||
// for the rest of the api codebase.
|
||||
export { AppError, ErrorCodes, type ErrorCode };
|
||||
|
||||
/**
|
||||
* Standard error response format
|
||||
@@ -68,7 +57,7 @@ export const handleError = (
|
||||
code: responseCode,
|
||||
field: isServerError ? undefined : error.field
|
||||
},
|
||||
error.statusCode
|
||||
error.statusCode as ContentfulStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { enrichObjectsWithUrls, extractKeysFromObjects } from '@api/utils/presigned-urls';
|
||||
import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
|
||||
|
||||
import { getAssetsByIds } from '@cio/db/queries/assets';
|
||||
import type { LessonById } from '@cio/db/queries/lesson';
|
||||
import type { TLesson } from '@cio/db/types';
|
||||
|
||||
// Extract types from schema
|
||||
type LessonVideo = NonNullable<TLesson['videos']>[number];
|
||||
type LessonDocument = NonNullable<TLesson['documents']>[number];
|
||||
|
||||
function mapProviderToVideoType(provider: string): LessonVideo['type'] {
|
||||
if (provider === 'youtube') return 'youtube';
|
||||
if (provider === 'google_drive') return 'google_drive';
|
||||
if (provider === 'generic' || provider === 'external_url') return 'generic';
|
||||
return 'upload';
|
||||
}
|
||||
|
||||
function mapAssetMimeTypeToDocumentType(mimeType: string | null, fallbackType: LessonDocument['type']) {
|
||||
if (!mimeType) {
|
||||
return fallbackType;
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/msword') {
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
return 'docx';
|
||||
}
|
||||
|
||||
return fallbackType;
|
||||
}
|
||||
|
||||
function applyCanonicalVideoMetadata(
|
||||
videos: LessonVideo[],
|
||||
assets: Awaited<ReturnType<typeof getAssetsByIds>>
|
||||
): LessonVideo[] {
|
||||
if (assets.length === 0) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
|
||||
return videos.map((video) => {
|
||||
if (!video.assetId) {
|
||||
return video;
|
||||
}
|
||||
|
||||
const asset = assetsById.get(video.assetId);
|
||||
if (!asset) {
|
||||
return video;
|
||||
}
|
||||
|
||||
const mergedMetadata = {
|
||||
...(video.metadata ?? {}),
|
||||
title: asset.title ?? video.metadata?.title,
|
||||
description: asset.description ?? video.metadata?.description,
|
||||
thumbnailUrl: asset.thumbnailUrl ?? video.metadata?.thumbnailUrl,
|
||||
duration: asset.durationSeconds ?? video.metadata?.duration,
|
||||
aspectRatio: asset.aspectRatio ?? video.metadata?.aspectRatio
|
||||
};
|
||||
|
||||
return {
|
||||
...video,
|
||||
type: mapProviderToVideoType(asset.provider),
|
||||
key: asset.storageKey ?? video.key,
|
||||
link: asset.provider === 'upload' ? video.link : (asset.sourceUrl ?? video.link),
|
||||
fileName: asset.title ?? video.fileName,
|
||||
metadata: mergedMetadata
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function applyCanonicalDocumentMetadata(
|
||||
documents: LessonDocument[],
|
||||
assets: Awaited<ReturnType<typeof getAssetsByIds>>
|
||||
): LessonDocument[] {
|
||||
if (assets.length === 0) {
|
||||
return documents;
|
||||
}
|
||||
|
||||
const assetsById = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
|
||||
return documents.map((document) => {
|
||||
if (!document.assetId) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const asset = assetsById.get(document.assetId);
|
||||
if (!asset) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return {
|
||||
...document,
|
||||
type: mapAssetMimeTypeToDocumentType(asset.mimeType, document.type),
|
||||
key: asset.storageKey ?? document.key,
|
||||
link: asset.provider === 'upload' ? document.link : (asset.sourceUrl ?? document.link),
|
||||
name: asset.title ?? document.name,
|
||||
size: asset.byteSize ?? document.size
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches lesson with presigned URLs for videos and documents
|
||||
* @param lesson Lesson to enrich
|
||||
* @returns Lesson with presigned URLs added
|
||||
*/
|
||||
export async function enrichLessonWithPresignedUrls(lesson: LessonById): Promise<LessonById> {
|
||||
// videos and documents are jsonb fields from the database, now properly typed
|
||||
// Normalize them to arrays safely (handles null/undefined cases)
|
||||
const videos = lesson.videos || [];
|
||||
const documents = lesson.documents || [];
|
||||
const videoAssetIds = videos.map((video) => video.assetId).filter((assetId): assetId is string => Boolean(assetId));
|
||||
const documentAssetIds = documents
|
||||
.map((document) => document.assetId)
|
||||
.filter((assetId): assetId is string => Boolean(assetId));
|
||||
const assetIds = Array.from(new Set([...videoAssetIds, ...documentAssetIds]));
|
||||
const canonicalAssets = assetIds.length ? await getAssetsByIds(assetIds) : [];
|
||||
const canonicalVideos = applyCanonicalVideoMetadata(videos, canonicalAssets);
|
||||
const canonicalDocuments = applyCanonicalDocumentMetadata(documents, canonicalAssets);
|
||||
|
||||
const videoKeys = extractKeysFromObjects(canonicalVideos.filter((video) => video.type === 'upload'));
|
||||
const docKeys = extractKeysFromObjects(canonicalDocuments);
|
||||
|
||||
// Early return if no keys to process
|
||||
if (videoKeys.length === 0 && docKeys.length === 0) {
|
||||
return lesson;
|
||||
}
|
||||
|
||||
// Generate presigned URLs in parallel
|
||||
const [videoUrls, docUrls] = await Promise.all([
|
||||
generateVideoDownloadPresignedUrls(videoKeys),
|
||||
generateDocumentDownloadPresignedUrls(docKeys)
|
||||
]);
|
||||
|
||||
return {
|
||||
...lesson,
|
||||
videos: enrichObjectsWithUrls<LessonVideo>(canonicalVideos, videoUrls),
|
||||
documents: enrichObjectsWithUrls<LessonDocument>(canonicalDocuments, docUrls)
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { Scalar } from '@scalar/hono-api-reference';
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { AuthSession } from '@api/types/auth';
|
||||
|
||||
export function configureOpenAPI(app: Hono<AuthSession>) {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Generic utilities for working with presigned URLs
|
||||
* These functions are reusable across multiple services that need to enrich
|
||||
* objects with presigned URLs based on keys
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts keys from an array of objects that have a `key` property
|
||||
* @param items Array of objects with a key property
|
||||
* @returns Array of string keys
|
||||
*/
|
||||
export function extractKeysFromObjects<T extends { key?: string }>(items: T[]): string[] {
|
||||
return items.reduce<string[]>((keys, item) => {
|
||||
if (item.key) {
|
||||
keys.push(item.key);
|
||||
}
|
||||
return keys;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches an array of objects with URLs from a key-to-URL mapping
|
||||
* Adds a `link` property to objects that have a matching key in the urls record
|
||||
* @param items Array of objects to enrich
|
||||
* @param urls Record mapping keys to presigned URLs
|
||||
* @returns New array with enriched objects (immutable)
|
||||
*/
|
||||
export function enrichObjectsWithUrls<T extends { key?: string }>(
|
||||
items: T[],
|
||||
urls: Record<string, string>
|
||||
): Array<T & { link?: string }> {
|
||||
return items.map((item) => {
|
||||
if (item.key && urls[item.key]) {
|
||||
return { ...item, link: urls[item.key] };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to extract keys from objects with a custom key extractor
|
||||
* @param items Array of items
|
||||
* @param keyExtractor Function to extract the key from an item
|
||||
* @returns Array of string keys
|
||||
*/
|
||||
export function extractKeysFromArray<T>(items: T[], keyExtractor: (item: T) => string | undefined): string[] {
|
||||
return items.reduce<string[]>((keys, item) => {
|
||||
const key = keyExtractor(item);
|
||||
if (key) {
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to enrich array items with URLs
|
||||
* @param items Array of items to enrich
|
||||
* @param urls Record mapping keys to presigned URLs
|
||||
* @param keyGetter Function to get the key from an item
|
||||
* @param urlSetter Function to set the URL on an item
|
||||
* @returns New array with enriched items (immutable)
|
||||
*/
|
||||
export function enrichArrayWithUrls<T>(
|
||||
items: T[],
|
||||
urls: Record<string, string>,
|
||||
keyGetter: (item: T) => string | undefined,
|
||||
urlSetter: (item: T, url: string) => T
|
||||
): T[] {
|
||||
return items.map((item) => {
|
||||
const key = keyGetter(item);
|
||||
if (key && urls[key]) {
|
||||
return urlSetter(item, urls[key]);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
RATE_LIMIT_KEY_PREFIX
|
||||
} from '@api/constants/rate-limiter';
|
||||
|
||||
import { redis, type RedisClient } from './redis';
|
||||
import { redis, type RedisClient } from '@cio/core/utils/redis/redis';
|
||||
|
||||
export class RedisRateLimiter {
|
||||
private redis: RedisClient;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { logRedisUnavailableOnce, redis } from './redis';
|
||||
import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
|
||||
|
||||
const KEY = (token: string) => `oauth-handoff:${token}`;
|
||||
const TTL_SECONDS = 120; // OAuth callbacks usually finish in < 2s; 2 minutes gives plenty of slack.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createClient, type RedisClientType } from 'redis';
|
||||
import { env } from '@api/config/env';
|
||||
|
||||
// Create Redis client
|
||||
const client: RedisClientType = createClient({
|
||||
url: env.REDIS_URL
|
||||
});
|
||||
|
||||
let hasLoggedRedisUnavailable = false;
|
||||
|
||||
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
|
||||
if (hasLoggedRedisUnavailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasLoggedRedisUnavailable = true;
|
||||
|
||||
if (error) {
|
||||
console.warn(message, error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
// Handle connection events
|
||||
client.on('error', (err) => {
|
||||
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
console.log('Redis Client Connected');
|
||||
});
|
||||
|
||||
// Track connection state
|
||||
let isConnected = false;
|
||||
|
||||
export async function connectRedis(): Promise<void> {
|
||||
if (!env.REDIS_URL) {
|
||||
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
|
||||
return;
|
||||
}
|
||||
if (isConnected) return;
|
||||
try {
|
||||
await client.connect();
|
||||
isConnected = true;
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
|
||||
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Export the client (connection will be established before server starts)
|
||||
export const redis: RedisClientType = client;
|
||||
|
||||
// Export type for use in other files
|
||||
export type RedisClient = RedisClientType;
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectCommandInput,
|
||||
GetObjectCommand,
|
||||
GetObjectCommandInput,
|
||||
PutObjectCommand,
|
||||
PutObjectCommandInput
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { getPresignS3Client, getS3Client, getStorageConfig } from '@api/config/storage';
|
||||
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
|
||||
|
||||
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
|
||||
|
||||
const PRESIGN_CACHE_TTL_SECONDS = 3000; // 50 min; presigned URLs typically valid 1 hour
|
||||
const PRESIGN_CACHE_KEY_PREFIX = 'presign:download:';
|
||||
|
||||
export async function uploadToS3(params: PutObjectCommandInput): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await getS3Client().send(new PutObjectCommand(params));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFromS3(
|
||||
params: GetObjectCommandInput
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
try {
|
||||
const data = await getS3Client().send(new GetObjectCommand(params));
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFromS3(params: DeleteObjectCommandInput): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await getS3Client().send(new DeleteObjectCommand(params));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates presigned URLs for download for a list of keys in a specific bucket.
|
||||
* Uses Redis cache to avoid repeated S3 getSignedUrl calls for the same file.
|
||||
* @param keys Array of S3 keys
|
||||
* @param bucketName Bucket name (e.g., 'videos', 'documents')
|
||||
* @returns Record mapping keys to presigned URLs
|
||||
*/
|
||||
export async function generateDownloadPresignedUrls(
|
||||
keys: string[],
|
||||
bucketName: string
|
||||
): Promise<Record<string, string>> {
|
||||
const signedUrls: Record<string, string> = {};
|
||||
const config = getStorageConfig();
|
||||
const client = getPresignS3Client();
|
||||
|
||||
if (keys.length === 0) {
|
||||
return signedUrls;
|
||||
}
|
||||
|
||||
const cacheKeys = keys.map((key) => `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${key}`);
|
||||
|
||||
let cachedValues: (string | null)[] | null = null;
|
||||
try {
|
||||
cachedValues = await redis.mGet(cacheKeys);
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
|
||||
}
|
||||
|
||||
const keysToGenerate: string[] = [];
|
||||
if (cachedValues) {
|
||||
cachedValues.forEach((val, i) => {
|
||||
if (val) {
|
||||
signedUrls[keys[i]] = val;
|
||||
} else {
|
||||
keysToGenerate.push(keys[i]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
keysToGenerate.push(...keys);
|
||||
}
|
||||
|
||||
if (keysToGenerate.length === 0) {
|
||||
return signedUrls;
|
||||
}
|
||||
|
||||
const urlPromises = keysToGenerate.map(async (key) => {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: config.presignDownloadExpiresSeconds
|
||||
});
|
||||
|
||||
return { key, presignedUrl };
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate presigned URL for key "${key}" in bucket "${bucketName}":`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(urlPromises);
|
||||
|
||||
try {
|
||||
const pipeline = redis.multi();
|
||||
results.forEach((result) => {
|
||||
if (result) {
|
||||
signedUrls[result.key] = result.presignedUrl;
|
||||
const cacheKey = `${PRESIGN_CACHE_KEY_PREFIX}${bucketName}:${result.key}`;
|
||||
pipeline.setEx(cacheKey, PRESIGN_CACHE_TTL_SECONDS, result.presignedUrl);
|
||||
}
|
||||
});
|
||||
await pipeline.exec();
|
||||
} catch (error) {
|
||||
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
|
||||
// URLs are already in signedUrls; cache write failure is non-fatal
|
||||
}
|
||||
|
||||
return signedUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presigned URL for upload
|
||||
* @param fileKey S3 key for the file
|
||||
* @param bucketName Bucket name (e.g., 'videos', 'documents')
|
||||
* @param contentType MIME type of the file
|
||||
* @returns Presigned URL for upload
|
||||
*/
|
||||
export async function generateUploadPresignedUrl(
|
||||
fileKey: string,
|
||||
bucketName: string,
|
||||
contentType: string
|
||||
): Promise<string> {
|
||||
const config = getStorageConfig();
|
||||
const client = getPresignS3Client();
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: fileKey,
|
||||
ContentType: contentType
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
const presignedUrl = await getSignedUrl(client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: config.presignUploadExpiresSeconds
|
||||
});
|
||||
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
/** 6h presigned GET for private transcript VTT objects (not Redis-cached). */
|
||||
export const TRANSCRIPT_VTT_PRESIGN_SECONDS = 21_600;
|
||||
|
||||
export async function generateTranscriptVttPresignedUrl(key: string, bucketName: string): Promise<string> {
|
||||
const client = getPresignS3Client();
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: key
|
||||
}) as GetSignedUrlParameters[1];
|
||||
|
||||
return getSignedUrl(client as GetSignedUrlParameters[0], command, {
|
||||
expiresIn: TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates presigned URLs for video download
|
||||
* @param keys Array of video keys
|
||||
* @returns Record mapping keys to presigned URLs
|
||||
*/
|
||||
export async function generateVideoDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
|
||||
const config = getStorageConfig();
|
||||
return generateDownloadPresignedUrls(keys, config.bucketVideos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates presigned URLs for document download
|
||||
* @param keys Array of document keys
|
||||
* @returns Record mapping keys to presigned URLs
|
||||
*/
|
||||
export async function generateDocumentDownloadPresignedUrls(keys: string[]): Promise<Record<string, string>> {
|
||||
const config = getStorageConfig();
|
||||
return generateDownloadPresignedUrls(keys, config.bucketDocuments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presigned URL for video upload
|
||||
* @param fileKey S3 key for the video file
|
||||
* @param contentType MIME type of the video file
|
||||
* @returns Presigned URL for upload
|
||||
*/
|
||||
export async function generateVideoUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
|
||||
const config = getStorageConfig();
|
||||
return generateUploadPresignedUrl(fileKey, config.bucketVideos, contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a presigned URL for document upload
|
||||
* @param fileKey S3 key for the document file
|
||||
* @param contentType MIME type of the document file
|
||||
* @returns Presigned URL for upload
|
||||
*/
|
||||
export async function generateDocumentUploadPresignedUrl(fileKey: string, contentType: string): Promise<string> {
|
||||
const config = getStorageConfig();
|
||||
return generateUploadPresignedUrl(fileKey, config.bucketDocuments, contentType);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
|
||||
|
||||
sanitized = stripSvgDataUrls(sanitized);
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
|
||||
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
|
||||
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
|
||||
if (html == null) return html;
|
||||
|
||||
return sanitizeHtml(html);
|
||||
}
|
||||
|
||||
export function sanitizeUnknownStrings<T>(value: T): T {
|
||||
if (typeof value === 'string') {
|
||||
return sanitizeHtml(value) as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeUnknownStrings(item)) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
sanitizeUnknownStrings(entryValue)
|
||||
]);
|
||||
|
||||
return Object.fromEntries(sanitizedEntries) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Tinybird event tracking for AI assistant observability.
|
||||
* Fire-and-forget: never blocks the agent, silently skipped when not configured.
|
||||
*/
|
||||
|
||||
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
|
||||
const TINYBIRD_BASE_URL = process.env.TINYBIRD_BASE_URL || 'https://api.tinybird.co';
|
||||
|
||||
/**
|
||||
* Send an event to Tinybird for analytics and debugging.
|
||||
* Safe to call without awaiting — errors are swallowed.
|
||||
*/
|
||||
export function trackAgentEvent(eventName: string, data: Record<string, unknown>): void {
|
||||
if (!TINYBIRD_TOKEN) return;
|
||||
|
||||
fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TINYBIRD_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ...data, timestamp: new Date().toISOString() })
|
||||
}).catch(() => {
|
||||
// Intentionally swallowed — observability must never break the agent
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event Names ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AgentEvent = {
|
||||
CHAT_STARTED: 'agent.chat.started',
|
||||
CHAT_COMPLETED: 'agent.chat.completed',
|
||||
CHAT_ERROR: 'agent.chat.error',
|
||||
TOOL_CALLED: 'agent.tool.called',
|
||||
TOOL_COMPLETED: 'agent.tool.completed',
|
||||
TOOL_FAILED: 'agent.tool.failed',
|
||||
DOCUMENT_UPLOADED: 'agent.document.uploaded',
|
||||
PLAN_GENERATED: 'agent.plan.generated',
|
||||
PLAN_EXECUTED: 'agent.plan.executed'
|
||||
} as const;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Safely extracts the file extension from a filename.
|
||||
* Returns an empty string if no extension is found.
|
||||
*/
|
||||
export function getExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') return '';
|
||||
const ext = path.extname(filename).split('.');
|
||||
return ext.length > 1 ? ext[ext.length - 1] : '';
|
||||
}
|
||||
|
||||
export function removeSpacesAndSpecialCharacters(fileName: string): string {
|
||||
return fileName
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9.-]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique file key for storage, preserving the file extension.
|
||||
* Throws an error if the filename is invalid or has no extension.
|
||||
*/
|
||||
export function generateFileKey(fileName: string): string {
|
||||
const ext = getExtension(fileName);
|
||||
if (!ext) {
|
||||
throw new Error('Invalid file name or missing file extension');
|
||||
}
|
||||
|
||||
const cleanedFileName = removeSpacesAndSpecialCharacters(fileName);
|
||||
|
||||
return `${nanoid()}-${cleanedFileName}`;
|
||||
}
|
||||
Reference in New Issue
Block a user