feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import type { JobsOptions } from 'bullmq';
|
||||
|
||||
import { JOB_NAMES, QUEUE_NAMES } from '../queues/names';
|
||||
import { QUEUE_DEFAULTS } from '../queues/defaults';
|
||||
import { getQueue } from '../queues/factories';
|
||||
import type { TSendEmailPayload } from '../payloads/emails';
|
||||
|
||||
/**
|
||||
* BullMQ rejects `:` in custom job ids (it uses `:` as a separator in its
|
||||
* Redis key scheme). Callers tend to pass keys like `welcome:<userId>` for
|
||||
* readability, so we normalize the boundary here instead of forcing every
|
||||
* caller to remember.
|
||||
*/
|
||||
function toJobId(idempotencyKey: string): string {
|
||||
return `email-${idempotencyKey.replace(/:/g, '-')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single `emails:send` job. BullMQ deduplicates on `jobId`, so
|
||||
* pass an `idempotencyKey` (e.g. `welcome:<userId>`) when the same domain
|
||||
* action might fire more than once.
|
||||
*/
|
||||
export async function enqueueEmailSend(
|
||||
payload: TSendEmailPayload,
|
||||
options: { idempotencyKey?: string } & JobsOptions = {}
|
||||
): Promise<string | undefined> {
|
||||
const { idempotencyKey, ...jobOptions } = options;
|
||||
|
||||
const job = await getQueue(QUEUE_NAMES.emails).add(JOB_NAMES.emails.send, payload, {
|
||||
...QUEUE_DEFAULTS[QUEUE_NAMES.emails],
|
||||
...(idempotencyKey ? { jobId: toJobId(idempotencyKey) } : {}),
|
||||
...jobOptions
|
||||
});
|
||||
|
||||
return job.id;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './media';
|
||||
export * from './emails';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { JOB_NAMES, QUEUE_NAMES } from '../queues/names';
|
||||
import { QUEUE_DEFAULTS } from '../queues/defaults';
|
||||
import { getFlowProducer, getQueue } from '../queues/factories';
|
||||
import type { TActorContext } from '../payloads/media';
|
||||
|
||||
export interface EnqueueLessonVideoPipelineInput {
|
||||
mediaJobId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
actorContext: TActorContext;
|
||||
/** Skip transcription (e.g. when no OpenAI key is configured). */
|
||||
withTranscription?: boolean;
|
||||
}
|
||||
|
||||
export interface EnqueueLessonVideoPipelineResult {
|
||||
/** BullMQ job id of the parent (transcribe-audio if enabled, otherwise generate-thumbnail). */
|
||||
rootJobId: string;
|
||||
/** All BullMQ job ids enqueued, keyed by job name. */
|
||||
jobIds: Record<string, string>;
|
||||
}
|
||||
|
||||
function collectFlowJobIds(
|
||||
tree: Awaited<ReturnType<ReturnType<typeof getFlowProducer>['add']>>
|
||||
): Record<string, string> {
|
||||
const jobIds: Record<string, string> = {};
|
||||
const visit = (node: typeof tree | undefined): void => {
|
||||
if (!node) return;
|
||||
|
||||
jobIds[node.job.name] = node.job.id ?? '';
|
||||
node.children?.forEach(visit);
|
||||
};
|
||||
|
||||
visit(tree);
|
||||
return jobIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the lesson-video post-processing pipeline.
|
||||
*
|
||||
* The flow looks like this:
|
||||
*
|
||||
* transcribe-audio (media-transcribe) <- parent
|
||||
* └── extract-audio (media) <- child
|
||||
* └── generate-thumbnail (media) <- grandchild (sequential)
|
||||
* └── probe-metadata (media) <- runs first
|
||||
*
|
||||
* BullMQ runs children before parents, so probe runs first, then thumbnail,
|
||||
* then audio extract, then the transcription provider call. Each step is
|
||||
* retried independently. If transcription is disabled we drop the
|
||||
* transcribe + extract-audio nodes and use generate-thumbnail as the root.
|
||||
*
|
||||
* Each node payload is intentionally small — workers re-fetch the media_job
|
||||
* + asset rows from the DB to get the latest state.
|
||||
*/
|
||||
export async function enqueueLessonVideoPipeline(
|
||||
input: EnqueueLessonVideoPipelineInput
|
||||
): Promise<EnqueueLessonVideoPipelineResult> {
|
||||
const { mediaJobId, assetId, storageKey, actorContext, withTranscription = true } = input;
|
||||
|
||||
const sharedData = { mediaJobId, assetId, storageKey, actorContext };
|
||||
const flow = getFlowProducer();
|
||||
|
||||
if (!withTranscription) {
|
||||
const job = await getQueue(QUEUE_NAMES.media).add(
|
||||
JOB_NAMES.media.generateThumbnail,
|
||||
sharedData,
|
||||
QUEUE_DEFAULTS[QUEUE_NAMES.media]
|
||||
);
|
||||
const probe = await getQueue(QUEUE_NAMES.media).add(
|
||||
JOB_NAMES.media.probeMetadata,
|
||||
sharedData,
|
||||
QUEUE_DEFAULTS[QUEUE_NAMES.media]
|
||||
);
|
||||
|
||||
return {
|
||||
rootJobId: job.id ?? '',
|
||||
jobIds: {
|
||||
[JOB_NAMES.media.generateThumbnail]: job.id ?? '',
|
||||
[JOB_NAMES.media.probeMetadata]: probe.id ?? ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const tree = await flow.add({
|
||||
name: JOB_NAMES.mediaTranscribe.transcribeAudio,
|
||||
queueName: QUEUE_NAMES.mediaTranscribe,
|
||||
data: { mediaJobId, assetId, actorContext },
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.mediaTranscribe],
|
||||
children: [
|
||||
{
|
||||
name: JOB_NAMES.media.extractAudio,
|
||||
queueName: QUEUE_NAMES.media,
|
||||
data: sharedData,
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.media],
|
||||
children: [
|
||||
{
|
||||
name: JOB_NAMES.media.generateThumbnail,
|
||||
queueName: QUEUE_NAMES.media,
|
||||
data: sharedData,
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.media],
|
||||
children: [
|
||||
{
|
||||
name: JOB_NAMES.media.probeMetadata,
|
||||
queueName: QUEUE_NAMES.media,
|
||||
data: sharedData,
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.media]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
rootJobId: tree.job.id ?? '',
|
||||
jobIds: collectFlowJobIds(tree)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue only extract-audio → transcribe-audio (new `media_job` row required).
|
||||
* Used for manual “generate transcript” when the initial pipeline had no OpenAI key.
|
||||
*/
|
||||
export async function enqueueTranscriptionOnly(input: {
|
||||
mediaJobId: string;
|
||||
assetId: string;
|
||||
storageKey: string;
|
||||
actorContext: TActorContext;
|
||||
}): Promise<EnqueueLessonVideoPipelineResult> {
|
||||
const { mediaJobId, assetId, storageKey, actorContext } = input;
|
||||
const sharedData = { mediaJobId, assetId, storageKey, actorContext };
|
||||
const flow = getFlowProducer();
|
||||
|
||||
const tree = await flow.add({
|
||||
name: JOB_NAMES.mediaTranscribe.transcribeAudio,
|
||||
queueName: QUEUE_NAMES.mediaTranscribe,
|
||||
data: { mediaJobId, assetId, actorContext },
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.mediaTranscribe],
|
||||
children: [
|
||||
{
|
||||
name: JOB_NAMES.media.extractAudio,
|
||||
queueName: QUEUE_NAMES.media,
|
||||
data: sharedData,
|
||||
opts: QUEUE_DEFAULTS[QUEUE_NAMES.media]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
rootJobId: tree.job.id ?? '',
|
||||
jobIds: collectFlowJobIds(tree)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user