feat: preview templates, thumbnail features, 2 new org templates

This commit is contained in:
Rotimi Best
2026-05-23 12:24:57 +01:00
parent 4fdd4115b9
commit 78ab13f54b
106 changed files with 8320 additions and 2040 deletions
+7 -28
View File
@@ -432,12 +432,10 @@ const agentCoreRouter = new Hono()
}
}
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
const isPaid = await isOrgOnPaidPlan(orgId);
const isOrgPaid = role === AgentRole.TEACHER ? await isOrgOnPaidPlan(orgId) : false;
if (!isPaid) {
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
}
if (role === AgentRole.TEACHER && !modelDescriptor.isFree && !isOrgPaid) {
throw new AppError('Upgrade required to use this AI model', 'UPGRADE_REQUIRED', 403);
}
// Students go through tutor policy (workspace toggle, pool, per-learner cap).
@@ -520,7 +518,8 @@ const agentCoreRouter = new Hono()
// 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
tutorSettings: studentPolicy?.settings,
isOrgOnPaidPlan: isOrgPaid
});
const contextMessageText = buildContextMessage(agentContext, {
@@ -531,7 +530,7 @@ const agentCoreRouter = new Hono()
const agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages);
: buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid });
const contextManaged = await buildModelContextMessages({
conversationId,
@@ -569,27 +568,7 @@ const agentCoreRouter = new Hono()
]
: 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
)
);
console.log(`[agent.chat] user=${user.id} messages=${modelMessages.length}`);
// Cache the growing conversation prefix: each turn reads the prior
// transcript at ~0.1x cost instead of reprocessing it at full price.
+1
View File
@@ -52,6 +52,7 @@ export const agentRunsRouter = new Hono()
courseId: data.courseId,
conversationId: data.conversationId,
phase: data.phase,
modelId: data.model,
approvedPlan: data.approvedPlan,
executionCursor: data.executionCursor,
sourceIds: data.sourceIds,
+22
View File
@@ -10,6 +10,7 @@ import {
getMediaJobSteps,
listMediaJobEnvelopes,
listMediaJobsForAssetEnvelopes,
startThumbnailRegenJob,
startTranscriptionOnlyMediaJob
} from '@api/services/jobs';
@@ -108,6 +109,27 @@ export const jobsRouter = new Hono()
}
}
)
.post(
'/media/asset/:assetId/regenerate-thumbnail',
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 startThumbnailRegenJob({
organizationId: orgId,
assetId,
triggeredByProfileId: user?.id ?? null
});
return c.json({ success: true, data: job }, 202);
} catch (error) {
return handleError(c, error, 'Failed to start thumbnail regeneration');
}
}
)
.post(
'/media/:jobId/cancel',
authMiddleware,
+40
View File
@@ -26,6 +26,7 @@ import {
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
import { getStorageConfig } from '@api/config/storage';
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
@@ -310,6 +311,45 @@ export async function updateAssetService(orgId: string, assetId: string, data: T
}
}
/**
* Set the chosen thumbnail URL for an asset. Accepts URLs that are either
* (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded
* URL on the org's media bucket. Anything else is rejected so callers can't
* point the asset at an arbitrary remote image.
*/
export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) {
try {
const asset = await getAssetById(assetId, orgId);
assertAssetExists(asset);
const candidates = (asset?.thumbnailCandidates ?? []) as string[];
const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null;
const isCandidate = candidates.includes(thumbnailUrl);
const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`));
if (!isCandidate && !isOnMediaBucket) {
throw new AppError(
'Thumbnail URL must be a generated candidate or a media-bucket upload',
ErrorCodes.VALIDATION_ERROR,
400
);
}
const updated = await updateAsset(assetId, orgId, { thumbnailUrl });
return assertAssetExists(updated);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(
error instanceof Error ? error.message : 'Failed to update asset thumbnail',
ErrorCodes.ASSET_UPDATE_FAILED,
500
);
}
}
export async function deleteAssetService(orgId: string, assetId: string) {
try {
await getAssetService(orgId, assetId);
+85 -1
View File
@@ -11,7 +11,12 @@ import {
hasActiveMediaJobForAsset
} from '@cio/db/queries';
import { getAssetById } from '@cio/db/queries/assets';
import { enqueueLessonVideoPipeline, enqueueTranscriptionOnly, isRedisConfigured } from '@cio/jobs';
import {
enqueueGenerateThumbnailOnly,
enqueueLessonVideoPipeline,
enqueueTranscriptionOnly,
isRedisConfigured
} from '@cio/jobs';
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
import type { TMediaJob } from '@db/types';
@@ -98,6 +103,85 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
}
}
export interface StartThumbnailRegenInput {
organizationId: string;
assetId: string;
triggeredByProfileId: string | null;
}
/**
* Manual thumbnail regeneration: create a new `media_job` row and enqueue only
* the `generate-thumbnail` step. Rejects when the asset has no usable storage
* key or when another media job is already running for it.
*/
export async function startThumbnailRegenJob(input: StartThumbnailRegenInput): Promise<TMediaJob> {
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 thumbnailed', 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: 'thumbnailing',
progressPercent: 0
});
if (!isRedisConfigured()) {
logRedisUnavailableOnce(
'Redis not configured: thumbnail regen 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 enqueueGenerateThumbnailOnly({
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('startThumbnailRegenJob enqueue failed:', error);
await updateMediaJob(job.id, {
status: 'failed',
stage: 'failed',
error: {
code: 'ENQUEUE_FAILED',
message: error instanceof Error ? error.message : 'Failed to enqueue thumbnail job'
}
});
throw new AppError(
error instanceof Error ? error : 'Failed to enqueue thumbnail job',
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
export interface StartMediaJobInput {
organizationId: string;
assetId: string;