feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import IORedis, { type Redis, type RedisOptions } from 'ioredis';
|
||||
|
||||
/**
|
||||
* Shared Redis connection factory for BullMQ.
|
||||
*
|
||||
* BullMQ requires a dedicated `ioredis` connection per Worker (the worker
|
||||
* `BLPOP`s on it indefinitely). For Queues / FlowProducers we can reuse a
|
||||
* single shared connection. This module exposes both a singleton (for
|
||||
* enqueue paths in the API) and a factory (for each Worker process).
|
||||
*
|
||||
* Self-hosted note: Redis is required to enqueue or process jobs. Callers
|
||||
* should fail loudly when `REDIS_URL` is not configured rather than silently
|
||||
* dropping work.
|
||||
*/
|
||||
|
||||
const DEFAULT_OPTIONS: RedisOptions = {
|
||||
// Required by BullMQ - blocking commands need this disabled to work.
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false
|
||||
};
|
||||
|
||||
export interface RedisConnectionOptions extends RedisOptions {
|
||||
/** Override the URL; falls back to REDIS_URL env. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class RedisNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'REDIS_URL is not set. ClassroomIO requires Redis for the BullMQ job queue. ' +
|
||||
'Set REDIS_URL in your environment to a reachable Redis instance.'
|
||||
);
|
||||
this.name = 'RedisNotConfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(override?: string): string {
|
||||
const url = override ?? process.env.REDIS_URL ?? '';
|
||||
if (!url) {
|
||||
throw new RedisNotConfiguredError();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand new ioredis connection. Use this for each Worker process.
|
||||
*/
|
||||
export function createRedisConnection(options: RedisConnectionOptions = {}): Redis {
|
||||
const { url, ...rest } = options;
|
||||
const resolved = resolveUrl(url);
|
||||
return new IORedis(resolved, { ...DEFAULT_OPTIONS, ...rest });
|
||||
}
|
||||
|
||||
let sharedConnection: Redis | undefined;
|
||||
|
||||
/**
|
||||
* Lazily-created shared connection used by Queue/FlowProducer instances on
|
||||
* the API side. Throws `RedisNotConfiguredError` if `REDIS_URL` is missing.
|
||||
*/
|
||||
export function getSharedRedisConnection(): Redis {
|
||||
if (!sharedConnection) {
|
||||
sharedConnection = createRedisConnection();
|
||||
}
|
||||
|
||||
return sharedConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `REDIS_URL` is set. Useful for graceful enqueue fallbacks
|
||||
* during local development without Redis.
|
||||
*/
|
||||
export function isRedisConfigured(): boolean {
|
||||
return Boolean(process.env.REDIS_URL);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './connection';
|
||||
export * from './status';
|
||||
export * from './queues';
|
||||
export * from './payloads';
|
||||
export * from './enqueue';
|
||||
|
||||
export type { Job, JobsOptions, Worker, WorkerOptions, FlowJob } from 'bullmq';
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
/**
|
||||
* Payload for an `emails:send` job that uses a registered `@cio/email`
|
||||
* template id. Field shape is validated again inside the worker via the
|
||||
* template's Zod schema, so we keep the on-the-wire schema permissive.
|
||||
*/
|
||||
export const ZSendTemplateEmailPayload = z.object({
|
||||
kind: z.literal('template'),
|
||||
template: z.string().min(1),
|
||||
to: z.string().email(),
|
||||
fields: z.record(z.string(), z.unknown()).default({}),
|
||||
from: z.string().optional(),
|
||||
replyTo: z.string().optional()
|
||||
});
|
||||
export type TSendTemplateEmailPayload = z.infer<typeof ZSendTemplateEmailPayload>;
|
||||
|
||||
/**
|
||||
* Payload for an `emails:send` job that carries free-form subject + content
|
||||
* (used by the public mail route and submission notifications that don't have
|
||||
* a registered template).
|
||||
*/
|
||||
export const ZSendRawEmailPayload = z.object({
|
||||
kind: z.literal('raw'),
|
||||
to: z.string().email(),
|
||||
subject: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
replyTo: z.string().optional()
|
||||
});
|
||||
export type TSendRawEmailPayload = z.infer<typeof ZSendRawEmailPayload>;
|
||||
|
||||
/**
|
||||
* Discriminated union BullMQ workers parse on entry. Adding a new email
|
||||
* delivery shape is a matter of appending another variant here + a branch in
|
||||
* the worker.
|
||||
*/
|
||||
export const ZSendEmailPayload = z.discriminatedUnion('kind', [ZSendTemplateEmailPayload, ZSendRawEmailPayload]);
|
||||
export type TSendEmailPayload = z.infer<typeof ZSendEmailPayload>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './media';
|
||||
export * from './emails';
|
||||
export * from './maintenance';
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZRetentionCompactPayload = z.object({
|
||||
/** Override default retention windows (in days). Optional. */
|
||||
windowDays: z
|
||||
.object({
|
||||
mediaJob: z.number().int().positive().optional(),
|
||||
jobStep: z.number().int().positive().optional(),
|
||||
deadLetterJob: z.number().int().positive().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
export type TRetentionCompactPayload = z.infer<typeof ZRetentionCompactPayload>;
|
||||
|
||||
export const ZDeadLetterCleanupPayload = z.object({
|
||||
olderThanDays: z.number().int().positive().default(180)
|
||||
});
|
||||
export type TDeadLetterCleanupPayload = z.infer<typeof ZDeadLetterCleanupPayload>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
/**
|
||||
* Payloads for the `media` and `media-transcribe` queues.
|
||||
*
|
||||
* Every payload carries the `mediaJobId` (DB run id) and `assetId` so workers
|
||||
* never need to re-derive context. `actorContext` is snapshotted at enqueue
|
||||
* time per the plan's authorization-on-resume rule.
|
||||
*/
|
||||
|
||||
export const ZActorContext = z.object({
|
||||
userId: z.string().uuid().nullable(),
|
||||
organizationId: z.string().uuid(),
|
||||
locale: z.string().optional()
|
||||
});
|
||||
export type TActorContext = z.infer<typeof ZActorContext>;
|
||||
|
||||
export const ZProbeMetadataPayload = z.object({
|
||||
mediaJobId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
storageKey: z.string().min(1),
|
||||
actorContext: ZActorContext
|
||||
});
|
||||
export type TProbeMetadataPayload = z.infer<typeof ZProbeMetadataPayload>;
|
||||
|
||||
export const ZGenerateThumbnailPayload = z.object({
|
||||
mediaJobId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
storageKey: z.string().min(1),
|
||||
actorContext: ZActorContext,
|
||||
/** Seconds into the video to grab. Defaults to 1.0 in the worker. */
|
||||
atSeconds: z.number().nonnegative().optional()
|
||||
});
|
||||
export type TGenerateThumbnailPayload = z.infer<typeof ZGenerateThumbnailPayload>;
|
||||
|
||||
export const ZExtractAudioPayload = z.object({
|
||||
mediaJobId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
storageKey: z.string().min(1),
|
||||
actorContext: ZActorContext
|
||||
});
|
||||
export type TExtractAudioPayload = z.infer<typeof ZExtractAudioPayload>;
|
||||
|
||||
export const ZTranscribeAudioPayload = z.object({
|
||||
mediaJobId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
actorContext: ZActorContext,
|
||||
/** Provider override; defaults to `openai` in the worker. */
|
||||
provider: z.enum(['openai', 'assemblyai', 'deepgram']).optional()
|
||||
});
|
||||
export type TTranscribeAudioPayload = z.infer<typeof ZTranscribeAudioPayload>;
|
||||
|
||||
export const ZCompressVideoPayload = z.object({
|
||||
mediaJobId: z.string().uuid(),
|
||||
assetId: z.string().uuid(),
|
||||
storageKey: z.string().min(1),
|
||||
actorContext: ZActorContext,
|
||||
options: z
|
||||
.object({
|
||||
crf: z.number().int().min(0).max(51).optional(),
|
||||
scale: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
export type TCompressVideoPayload = z.infer<typeof ZCompressVideoPayload>;
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { JobsOptions, QueueOptions } from 'bullmq';
|
||||
|
||||
import { QUEUE_NAMES, type QueueName } from './names';
|
||||
|
||||
/**
|
||||
* Per-queue defaults. Tuned roughly for the workload:
|
||||
*
|
||||
* - Heavy/external work (media, transcription, webhooks): more attempts,
|
||||
* longer backoff, longer retention so we can replay.
|
||||
* - Light/internal work (emails, notifications, outbox): faster retry,
|
||||
* short retention.
|
||||
*/
|
||||
export const QUEUE_DEFAULTS: Record<QueueName, JobsOptions> = {
|
||||
[QUEUE_NAMES.media]: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
removeOnComplete: { age: 86_400, count: 1_000 },
|
||||
removeOnFail: { age: 7 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.mediaTranscribe]: {
|
||||
attempts: 4,
|
||||
backoff: { type: 'exponential', delay: 60_000 },
|
||||
removeOnComplete: { age: 86_400, count: 500 },
|
||||
removeOnFail: { age: 14 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.emails]: {
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: { age: 6 * 3_600, count: 5_000 },
|
||||
removeOnFail: { age: 7 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.notifications]: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: { age: 6 * 3_600, count: 5_000 },
|
||||
removeOnFail: { age: 7 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.webhooks]: {
|
||||
attempts: 8,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
removeOnComplete: { age: 86_400, count: 5_000 },
|
||||
removeOnFail: { age: 14 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.courseImports]: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
removeOnComplete: { age: 7 * 86_400, count: 500 },
|
||||
removeOnFail: { age: 14 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.onboardingBootstrap]: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
removeOnComplete: { age: 7 * 86_400, count: 500 },
|
||||
removeOnFail: { age: 14 * 86_400 }
|
||||
},
|
||||
[QUEUE_NAMES.maintenance]: {
|
||||
attempts: 2,
|
||||
backoff: { type: 'exponential', delay: 60_000 },
|
||||
removeOnComplete: { age: 86_400, count: 100 },
|
||||
removeOnFail: { age: 7 * 86_400 }
|
||||
}
|
||||
};
|
||||
|
||||
export function buildQueueOptions(name: QueueName, override?: Partial<QueueOptions>): QueueOptions {
|
||||
return {
|
||||
...override,
|
||||
defaultJobOptions: {
|
||||
...QUEUE_DEFAULTS[name],
|
||||
...(override?.defaultJobOptions ?? {})
|
||||
}
|
||||
} as QueueOptions;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FlowProducer, Queue } from 'bullmq';
|
||||
|
||||
import { getSharedRedisConnection } from '../connection';
|
||||
import { QUEUE_NAMES, type QueueName } from './names';
|
||||
import { buildQueueOptions } from './defaults';
|
||||
|
||||
/**
|
||||
* Lazy queue + flow-producer accessors backed by the shared Redis
|
||||
* connection. Workers should NOT use these — they need their own Redis
|
||||
* connection per process. These are for the API enqueue path.
|
||||
*/
|
||||
|
||||
const queues = new Map<QueueName, Queue>();
|
||||
let flowProducer: FlowProducer | undefined;
|
||||
|
||||
export function getQueue(name: QueueName): Queue {
|
||||
let queue = queues.get(name);
|
||||
if (!queue) {
|
||||
queue = new Queue(name, {
|
||||
...buildQueueOptions(name),
|
||||
connection: getSharedRedisConnection()
|
||||
});
|
||||
queues.set(name, queue);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
export function getFlowProducer(): FlowProducer {
|
||||
if (!flowProducer) {
|
||||
flowProducer = new FlowProducer({ connection: getSharedRedisConnection() });
|
||||
}
|
||||
|
||||
return flowProducer;
|
||||
}
|
||||
|
||||
/** Eagerly close every cached queue/flow producer. Useful in tests. */
|
||||
export async function closeAllQueues(): Promise<void> {
|
||||
await Promise.all(Array.from(queues.values()).map((queue) => queue.close()));
|
||||
queues.clear();
|
||||
|
||||
if (flowProducer) {
|
||||
await flowProducer.close();
|
||||
flowProducer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns every queue name in registration order. */
|
||||
export function listQueueNames(): QueueName[] {
|
||||
return Object.values(QUEUE_NAMES);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './names';
|
||||
export * from './defaults';
|
||||
export * from './factories';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* BullMQ queue names. Always import from here so producers and workers stay
|
||||
* in sync, and so it's trivial to grep for everything that touches a queue.
|
||||
*/
|
||||
export const QUEUE_NAMES = {
|
||||
media: 'media',
|
||||
mediaTranscribe: 'media-transcribe',
|
||||
emails: 'emails',
|
||||
notifications: 'notifications',
|
||||
webhooks: 'webhooks',
|
||||
courseImports: 'course-imports',
|
||||
onboardingBootstrap: 'onboarding-bootstrap',
|
||||
maintenance: 'maintenance'
|
||||
} as const;
|
||||
|
||||
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];
|
||||
|
||||
/**
|
||||
* BullMQ job names within each queue. Workers route on `(queueName, jobName)`,
|
||||
* so keep the strings here too.
|
||||
*/
|
||||
export const JOB_NAMES = {
|
||||
media: {
|
||||
probeMetadata: 'probe-metadata',
|
||||
generateThumbnail: 'generate-thumbnail',
|
||||
extractAudio: 'extract-audio',
|
||||
compressVideo: 'compress-video'
|
||||
},
|
||||
mediaTranscribe: {
|
||||
transcribeAudio: 'transcribe-audio'
|
||||
},
|
||||
emails: {
|
||||
send: 'send'
|
||||
},
|
||||
webhooks: {
|
||||
deliver: 'deliver'
|
||||
},
|
||||
maintenance: {
|
||||
retentionCompact: 'retention-compact',
|
||||
deadLetterCleanup: 'dead-letter-cleanup'
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Shared run/job status types and the polling envelope.
|
||||
*
|
||||
* These are wire-level types: the API returns them, the dashboard polls them.
|
||||
* Keep them stable — they're the contract for every queued domain.
|
||||
*/
|
||||
|
||||
export const JOB_STATUS = {
|
||||
queued: 'queued',
|
||||
running: 'running',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
canceled: 'canceled'
|
||||
} as const;
|
||||
|
||||
export type JobStatus = (typeof JOB_STATUS)[keyof typeof JOB_STATUS];
|
||||
|
||||
export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = [
|
||||
JOB_STATUS.completed,
|
||||
JOB_STATUS.failed,
|
||||
JOB_STATUS.canceled
|
||||
];
|
||||
|
||||
export function isTerminalStatus(status: string | null | undefined): status is JobStatus {
|
||||
return Boolean(status) && (TERMINAL_JOB_STATUSES as readonly string[]).includes(status as string);
|
||||
}
|
||||
|
||||
export interface JobError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface JobEvent {
|
||||
at: string;
|
||||
type: string;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single envelope every `GET /jobs/:id` endpoint returns. Domain-specific
|
||||
* routes can extend `result`/`events` but must not change the shape.
|
||||
*/
|
||||
export interface JobEnvelope<TResult = Record<string, unknown> | null> {
|
||||
job: {
|
||||
id: string;
|
||||
domain: string;
|
||||
status: JobStatus;
|
||||
stage: string | null;
|
||||
progressPercent: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
result: TResult;
|
||||
error: JobError | null;
|
||||
warnings?: string[];
|
||||
cancelRequestedAt?: string | null;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
events: JobEvent[];
|
||||
/** Suggested next poll delay in ms. The dashboard poller uses this. */
|
||||
nextPollMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default backoff schedule for the dashboard poller. Returns the suggested
|
||||
* delay in ms based on the job's status and how many polls have happened.
|
||||
*/
|
||||
export function suggestNextPollMs(status: JobStatus, pollCount: number): number {
|
||||
if (isTerminalStatus(status)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pollCount < 5) return 1_000;
|
||||
if (pollCount < 15) return 2_000;
|
||||
return 5_000;
|
||||
}
|
||||
Reference in New Issue
Block a user