feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
|
||||
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
|
||||
type Recipient = string | string[];
|
||||
|
||||
interface CommonOptions {
|
||||
/**
|
||||
* Stable key used as BullMQ `jobId` for idempotent enqueue (e.g.
|
||||
* `welcome:<userId>`). When omitted the job gets an auto-generated id, so
|
||||
* duplicate calls produce duplicate emails.
|
||||
*/
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOptions {
|
||||
to: Recipient;
|
||||
fields: z.infer<EmailSchemaFor<TId>>;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export interface EnqueueRawEmailInput extends CommonOptions {
|
||||
to: Recipient;
|
||||
subject: string;
|
||||
content: string;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export interface EnqueueResult {
|
||||
/** BullMQ job ids — one per recipient. */
|
||||
jobIds: string[];
|
||||
}
|
||||
|
||||
function toRecipientArray(to: Recipient): string[] {
|
||||
return Array.isArray(to) ? to : [to];
|
||||
}
|
||||
|
||||
function recipientKey(base: string | undefined, recipient: string, total: number): string | undefined {
|
||||
if (!base) return undefined;
|
||||
|
||||
return total === 1 ? base : `${base}:${recipient}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue of a registered template email. Validates `fields`
|
||||
* against the template's Zod schema up front so bad payloads fail in the
|
||||
* domain handler instead of silently inside the worker.
|
||||
*
|
||||
* BullMQ tracks send state, retries, and failure history — no DB ledger is
|
||||
* written. Final failures are recorded in `dead_letter_job` by the worker
|
||||
* for operator triage.
|
||||
*/
|
||||
export async function enqueueTransactionalEmail<TId extends EmailId>(
|
||||
template: TId,
|
||||
input: EnqueueTemplateEmailInput<TId>
|
||||
): Promise<EnqueueResult> {
|
||||
const definition = EmailRegistry.get(template);
|
||||
if (!definition) {
|
||||
throw new Error(`Email template "${template}" is not registered`);
|
||||
}
|
||||
|
||||
const validatedFields = definition.schema.parse(input.fields) as Record<string, unknown>;
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
|
||||
return { jobIds: [] };
|
||||
}
|
||||
|
||||
const recipients = toRecipientArray(input.to);
|
||||
const jobIds: string[] = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const jobId = await enqueueEmailSend(
|
||||
{
|
||||
kind: 'template',
|
||||
template,
|
||||
to: recipient,
|
||||
fields: validatedFields,
|
||||
from: input.from,
|
||||
replyTo: input.replyTo
|
||||
},
|
||||
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
|
||||
);
|
||||
|
||||
if (jobId) jobIds.push(jobId);
|
||||
}
|
||||
|
||||
return { jobIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget enqueue of a free-form subject/content email — used by the
|
||||
* public mail route and submission notifications that don't have a registered
|
||||
* template.
|
||||
*/
|
||||
export async function enqueueRawEmail(input: EnqueueRawEmailInput): Promise<EnqueueResult> {
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce('Redis not configured: emails not enqueued. Set REDIS_URL and run apps/jobs to send them.');
|
||||
return { jobIds: [] };
|
||||
}
|
||||
|
||||
const recipients = toRecipientArray(input.to);
|
||||
const jobIds: string[] = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const jobId = await enqueueEmailSend(
|
||||
{
|
||||
kind: 'raw',
|
||||
to: recipient,
|
||||
subject: input.subject,
|
||||
content: input.content,
|
||||
from: input.from,
|
||||
replyTo: input.replyTo
|
||||
},
|
||||
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
|
||||
);
|
||||
|
||||
if (jobId) jobIds.push(jobId);
|
||||
}
|
||||
|
||||
return { jobIds };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './media-jobs';
|
||||
export * from './email-jobs';
|
||||
@@ -0,0 +1,248 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { JOB_STATUS, type JobEnvelope, type JobStatus, suggestNextPollMs } from '@cio/jobs/status';
|
||||
import {
|
||||
createMediaJob,
|
||||
getMediaJobById,
|
||||
listJobStepsByRun,
|
||||
listLatestMediaJobsByAsset,
|
||||
listMediaJobsByIds,
|
||||
requestMediaJobCancel,
|
||||
updateMediaJob,
|
||||
hasActiveMediaJobForAsset
|
||||
} from '@cio/db/queries';
|
||||
import { getAssetById } from '@cio/db/queries/assets';
|
||||
import { enqueueLessonVideoPipeline, enqueueTranscriptionOnly, isRedisConfigured } from '@cio/jobs';
|
||||
|
||||
import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
|
||||
import type { TMediaJob } from '@db/types';
|
||||
|
||||
export interface StartTranscriptionOnlyMediaJobInput {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
triggeredByProfileId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual transcription: new `media_job` + extract-audio → transcribe-audio only.
|
||||
* Requires OpenAI key, upload video asset, and no other active media_job for the asset.
|
||||
*/
|
||||
export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOnlyMediaJobInput): Promise<TMediaJob> {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new AppError('Transcription not configured', ErrorCodes.OPENAI_KEY_MISSING, 503);
|
||||
}
|
||||
|
||||
const asset = await getAssetById(input.assetId);
|
||||
if (!asset || asset.organizationId !== input.organizationId) {
|
||||
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
|
||||
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
|
||||
}
|
||||
|
||||
const hasActive = await hasActiveMediaJobForAsset(input.assetId, input.organizationId);
|
||||
if (hasActive) {
|
||||
throw new AppError('A media job is already running for this asset', ErrorCodes.CONFLICT, 409);
|
||||
}
|
||||
|
||||
const job = await createMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
status: 'queued',
|
||||
stage: 'transcribing',
|
||||
progressPercent: 0
|
||||
});
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce(
|
||||
'Redis not configured: transcription media job created but no BullMQ flow enqueued. ' +
|
||||
'Set REDIS_URL and run apps/jobs to process this job.'
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
try {
|
||||
const enqueueResult = await enqueueTranscriptionOnly({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: asset.storageKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await updateMediaJob(job.id, {
|
||||
rootJobId: enqueueResult.rootJobId,
|
||||
jobIds: enqueueResult.jobIds
|
||||
});
|
||||
|
||||
return updated ?? job;
|
||||
} catch (error) {
|
||||
console.error('startTranscriptionOnlyMediaJob enqueue failed:', error);
|
||||
await updateMediaJob(job.id, {
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
error: {
|
||||
code: 'ENQUEUE_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Failed to enqueue transcription job'
|
||||
}
|
||||
});
|
||||
|
||||
throw new AppError(
|
||||
error instanceof Error ? error : 'Failed to enqueue transcription job',
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StartMediaJobInput {
|
||||
organizationId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
triggeredByProfileId: string | null;
|
||||
/** Set to false for assets without an OpenAI key (skip transcribe leg). */
|
||||
withTranscription?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `media_job` row and enqueue the BullMQ flow. Falls back to a
|
||||
* persisted `queued` row with no BullMQ job id when Redis is unavailable so
|
||||
* the API returns a stable status envelope and a follow-up `start` worker
|
||||
* can pick it up later. The enqueue itself fails loudly (the DB row stays
|
||||
* around so an operator can replay it).
|
||||
*/
|
||||
export async function startMediaJob(input: StartMediaJobInput): Promise<TMediaJob> {
|
||||
const job = await createMediaJob({
|
||||
organizationId: input.organizationId,
|
||||
assetId: input.assetId,
|
||||
triggeredByProfileId: input.triggeredByProfileId,
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
progressPercent: 0
|
||||
});
|
||||
|
||||
if (!isRedisConfigured()) {
|
||||
logRedisUnavailableOnce(
|
||||
'Redis not configured: media job created but no BullMQ flow enqueued. ' +
|
||||
'Set REDIS_URL and run apps/jobs to process this job.'
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
try {
|
||||
const enqueueResult = await enqueueLessonVideoPipeline({
|
||||
mediaJobId: job.id,
|
||||
assetId: input.assetId,
|
||||
storageKey: input.storageKey,
|
||||
actorContext: {
|
||||
userId: input.triggeredByProfileId,
|
||||
organizationId: input.organizationId
|
||||
},
|
||||
withTranscription: input.withTranscription
|
||||
});
|
||||
|
||||
const updated = await updateMediaJob(job.id, {
|
||||
rootJobId: enqueueResult.rootJobId,
|
||||
jobIds: enqueueResult.jobIds
|
||||
});
|
||||
|
||||
return updated ?? job;
|
||||
} catch (error) {
|
||||
console.error('startMediaJob enqueue failed:', error);
|
||||
await updateMediaJob(job.id, {
|
||||
status: 'failed',
|
||||
stage: 'failed',
|
||||
error: {
|
||||
code: 'ENQUEUE_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Failed to enqueue media job'
|
||||
}
|
||||
});
|
||||
|
||||
throw new AppError(error instanceof Error ? error : 'Failed to enqueue media job', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetMediaJobEnvelopeOptions {
|
||||
pollCount?: number;
|
||||
}
|
||||
|
||||
function toEnvelope(job: TMediaJob, options: GetMediaJobEnvelopeOptions = {}): JobEnvelope {
|
||||
const status = job.status as JobStatus;
|
||||
const pollCount = options.pollCount ?? 0;
|
||||
|
||||
return {
|
||||
job: {
|
||||
id: job.id,
|
||||
domain: 'media',
|
||||
status,
|
||||
stage: job.stage,
|
||||
progressPercent: job.progressPercent,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
result: job.result ?? null,
|
||||
error: job.error ?? null,
|
||||
warnings: job.warnings ?? [],
|
||||
cancelRequestedAt: job.cancelRequestedAt,
|
||||
attempt: job.attempt,
|
||||
maxAttempts: job.maxAttempts
|
||||
},
|
||||
events: [],
|
||||
nextPollMs: suggestNextPollMs(status, pollCount)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaJobEnvelope(
|
||||
jobId: string,
|
||||
orgId: string,
|
||||
options: GetMediaJobEnvelopeOptions = {}
|
||||
): Promise<JobEnvelope> {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return toEnvelope(job, options);
|
||||
}
|
||||
|
||||
export async function listMediaJobEnvelopes(jobIds: string[], orgId: string): Promise<Record<string, JobEnvelope>> {
|
||||
if (jobIds.length === 0) return {};
|
||||
|
||||
const rows = await listMediaJobsByIds(jobIds, orgId);
|
||||
const map: Record<string, JobEnvelope> = {};
|
||||
for (const row of rows) {
|
||||
map[row.id] = toEnvelope(row);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function listMediaJobsForAssetEnvelopes(assetId: string, orgId: string): Promise<JobEnvelope[]> {
|
||||
const rows = await listLatestMediaJobsByAsset(assetId, orgId);
|
||||
return rows.map((row) => toEnvelope(row));
|
||||
}
|
||||
|
||||
export async function cancelMediaJob(jobId: string, orgId: string): Promise<JobEnvelope> {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.completed || job.status === JOB_STATUS.failed || job.status === JOB_STATUS.canceled) {
|
||||
return toEnvelope(job);
|
||||
}
|
||||
|
||||
const updated = await requestMediaJobCancel(jobId, orgId);
|
||||
return toEnvelope(updated ?? job);
|
||||
}
|
||||
|
||||
export async function getMediaJobSteps(jobId: string, orgId: string) {
|
||||
const job = await getMediaJobById(jobId, orgId);
|
||||
if (!job) {
|
||||
throw new AppError('Media job not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
return listJobStepsByRun('media', jobId);
|
||||
}
|
||||
Reference in New Issue
Block a user