feat(media): delete videos from the media library
Add an admin flow to permanently delete a media asset and clean up everything it leaves behind. - Delete dialog with a destructive, irreversible confirmation; deletion is blocked while the video is still attached and shows the in-use lessons so the admin can detach first. - Usage list resolves lesson/course context for each attachment, links to the lesson editor via an anchor (right-click "open in new tab"), and exposes a refresh button to re-check usage after detaching elsewhere. - Storage cleanup runs as a background maintenance job: the producer captures the asset's object keys/prefixes up front and an idempotent worker sweeps HLS segments, thumbnails, audio, transcript, and the raw upload from object storage. Also harden usage accounting so stale attachments cannot trap an asset: - asset_usages has no FK to its polymorphic target, so prune a lesson's usage rows whenever the lesson is hard-deleted (single + bulk paths). - Usage resolution drops orphaned lesson usages (lesson gone or course soft-deleted) so they neither appear in the list nor block deletion. Document the icon-only button convention (variant="secondary") in AGENTS.md.
This commit is contained in:
@@ -17,7 +17,6 @@ import type {
|
||||
} from '@cio/utils/validation/assets';
|
||||
import type { TTranscriptResponse, TUpdateTranscript } from '@cio/utils/validation/media';
|
||||
import {
|
||||
countAssetUsagesByAsset,
|
||||
createAssetUsage,
|
||||
createHlsAssetPlaceholder,
|
||||
createOrGetAssetByStorageKey,
|
||||
@@ -32,14 +31,17 @@ import {
|
||||
listAssetUsagesByAsset,
|
||||
updateAsset
|
||||
} from '@cio/db/queries/assets';
|
||||
import { getMediaTranscriptByAsset, updateMediaTranscriptContent } from '@cio/db/queries';
|
||||
import { getLessonNavInfoByIds, getMediaTranscriptByAsset, updateMediaTranscriptContent } from '@cio/db/queries';
|
||||
|
||||
import {
|
||||
deleteFromS3,
|
||||
generateTranscriptVttPresignedUrl,
|
||||
generateUploadPresignedUrl,
|
||||
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||
} from '../../utils/s3';
|
||||
import { getS3Client, getStorageConfig } from '../../config/storage';
|
||||
import { enqueueAssetStorageCleanup, isRedisConfigured, type TAssetStorageCleanupPayload } from '@cio/jobs';
|
||||
import type { TAsset } from '@cio/db/types';
|
||||
|
||||
const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
||||
|
||||
@@ -363,17 +365,106 @@ export async function selectAssetThumbnailService(orgId: string, assetId: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every object-storage location backing an asset so a background job
|
||||
* can purge them after the DB row (and its cascaded rows) is deleted. Derived
|
||||
* media live under predictable per-asset prefixes; the raw upload is the only
|
||||
* standalone key (and HLS uploads leave `storageKey` null).
|
||||
*/
|
||||
function buildAssetStorageCleanupPayload(asset: TAsset): TAssetStorageCleanupPayload {
|
||||
const config = getStorageConfig();
|
||||
|
||||
const prefixes: TAssetStorageCleanupPayload['prefixes'] = [
|
||||
{ bucket: config.bucketVideos, prefix: `${asset.id}/` },
|
||||
{ bucket: config.bucketMedia, prefix: `thumbnails/${asset.id}/` },
|
||||
{ bucket: config.bucketMedia, prefix: `audio/${asset.id}/` },
|
||||
{ bucket: config.bucketMedia, prefix: `transcripts/${asset.id}/` }
|
||||
];
|
||||
|
||||
const keys: TAssetStorageCleanupPayload['keys'] = [];
|
||||
if (asset.provider === 'upload' && asset.storageKey) {
|
||||
const bucket = asset.kind === 'document' ? config.bucketDocuments : config.bucketVideos;
|
||||
keys.push({ bucket, key: asset.storageKey });
|
||||
}
|
||||
|
||||
return { assetId: asset.id, organizationId: asset.organizationId, prefixes, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge an asset's object-storage files. Idempotent — missing objects are
|
||||
* fine. Runs in the maintenance worker after the asset row is already gone.
|
||||
*/
|
||||
export async function purgeAssetStorage(payload: TAssetStorageCleanupPayload): Promise<void> {
|
||||
for (const { bucket, prefix } of payload.prefixes) {
|
||||
await deleteHlsAssetObjects(bucket, prefix);
|
||||
}
|
||||
|
||||
for (const { bucket, key } of payload.keys) {
|
||||
const result = await deleteFromS3({ Bucket: bucket, Key: key });
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to delete ${bucket}/${key}: ${result.error ?? 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an asset's usages to only the *live* ones. `asset_usages.target_id`
|
||||
* has no FK to its target table, so a hard-deleted lesson can leave an orphaned
|
||||
* usage row behind. Those rows are not real attachments — they must not block
|
||||
* deletion or appear in the usage list — so lesson usages whose lesson no longer
|
||||
* exists are dropped. Surviving lesson usages are enriched with course context
|
||||
* for the "go to lesson" link; non-lesson targets carry null enrichment fields.
|
||||
*/
|
||||
async function resolveLiveAssetUsages(orgId: string, assetId: string) {
|
||||
const usages = await listAssetUsagesByAsset(assetId, orgId);
|
||||
const lessonIds = usages.filter((usage) => usage.targetType === 'lesson').map((usage) => usage.targetId);
|
||||
const navById = new Map((await getLessonNavInfoByIds(lessonIds)).map((nav) => [nav.id, nav]));
|
||||
|
||||
return usages
|
||||
.filter((usage) => usage.targetType !== 'lesson' || navById.has(usage.targetId))
|
||||
.map((usage) => {
|
||||
const nav = usage.targetType === 'lesson' ? navById.get(usage.targetId) : undefined;
|
||||
|
||||
return {
|
||||
...usage,
|
||||
courseId: nav?.courseId ?? null,
|
||||
targetTitle: nav?.lessonTitle ?? null,
|
||||
courseTitle: nav?.courseTitle ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAssetService(orgId: string, assetId: string) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
const asset = await getAssetService(orgId, assetId);
|
||||
|
||||
const usageCount = await countAssetUsagesByAsset(assetId, orgId);
|
||||
if (usageCount > 0) {
|
||||
const liveUsages = await resolveLiveAssetUsages(orgId, assetId);
|
||||
if (liveUsages.length > 0) {
|
||||
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
|
||||
}
|
||||
|
||||
const cleanupPayload = buildAssetStorageCleanupPayload(asset);
|
||||
|
||||
const deleted = await deleteAsset(assetId, orgId);
|
||||
return assertAssetExists(deleted);
|
||||
assertAssetExists(deleted);
|
||||
|
||||
// Storage cleanup is a best-effort background sweep: a failed enqueue must
|
||||
// not fail the delete the user just confirmed. Orphaned objects can be
|
||||
// reconciled later, and the keys are logged here for traceability.
|
||||
if (isRedisConfigured()) {
|
||||
try {
|
||||
await enqueueAssetStorageCleanup(cleanupPayload);
|
||||
} catch (error) {
|
||||
console.error('deleteAssetService: failed to enqueue storage cleanup', { assetId, error });
|
||||
}
|
||||
} else {
|
||||
console.warn('deleteAssetService: Redis not configured, skipping storage cleanup enqueue', {
|
||||
assetId,
|
||||
cleanupPayload
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
@@ -390,13 +481,10 @@ export async function deleteAssetService(orgId: string, assetId: string) {
|
||||
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
|
||||
try {
|
||||
await getAssetService(orgId, assetId);
|
||||
const [usages, usageCount] = await Promise.all([
|
||||
listAssetUsagesByAsset(assetId, orgId),
|
||||
countAssetUsagesByAsset(assetId, orgId)
|
||||
]);
|
||||
const usages = await resolveLiveAssetUsages(orgId, assetId);
|
||||
|
||||
return {
|
||||
usageCount,
|
||||
usageCount: usages.length,
|
||||
usages
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId
|
||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||
import { deleteAssetUsagesByTarget } from '@cio/db/queries/assets';
|
||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
@@ -198,6 +199,8 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await deleteAssetUsagesByTarget('lesson', item.id, tx);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -232,6 +235,8 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
||||
if (!deleted) {
|
||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await deleteAssetUsagesByTarget('lesson', item.id, tx);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@cio/db/queries/lesson';
|
||||
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
||||
import { deleteAssetUsagesByTarget } from '@cio/db/queries/assets';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
|
||||
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
|
||||
import { resolveItemSlug } from '../course/slug';
|
||||
@@ -151,7 +153,20 @@ export async function updateLessonService(lessonId: string, data: TLessonUpdate)
|
||||
*/
|
||||
export async function deleteLessonService(lessonId: string): Promise<TLesson> {
|
||||
try {
|
||||
const deleted = await deleteLesson(lessonId);
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const lesson = await deleteLesson(lessonId, tx);
|
||||
if (!lesson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// asset_usages.target_id has no FK to lesson, so the lesson delete does
|
||||
// not cascade here. Prune its usage rows in the same transaction to avoid
|
||||
// orphaned attachments that would otherwise block deleting those assets.
|
||||
await deleteAssetUsagesByTarget('lesson', lessonId, tx);
|
||||
|
||||
return lesson;
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
||||
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
||||
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
|
||||
"db:rewrite-media-host": "tsx src/scripts/rewrite-media-host.ts",
|
||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||
|
||||
@@ -472,6 +472,33 @@ export async function deleteAssetUsage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every usage row pointing at a given polymorphic target (e.g. all
|
||||
* usages of a lesson that is being deleted). `asset_usages.target_id` has no
|
||||
* foreign key to the target table, so deleting the target does not cascade
|
||||
* here — callers must prune explicitly to avoid orphaned usage rows. Returns
|
||||
* the number of rows removed.
|
||||
*/
|
||||
export async function deleteAssetUsagesByTarget(
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
dbClient: DbOrTxClient = db
|
||||
): Promise<number> {
|
||||
try {
|
||||
const deleted = await dbClient
|
||||
.delete(schema.assetUsage)
|
||||
.where(and(eq(schema.assetUsage.targetType, targetType), eq(schema.assetUsage.targetId, targetId)))
|
||||
.returning({ id: schema.assetUsage.id });
|
||||
|
||||
return deleted.length;
|
||||
} catch (error) {
|
||||
console.error('deleteAssetUsagesByTarget error:', error);
|
||||
throw new Error(
|
||||
`Failed to delete asset usages by target: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssetUsagesByAsset(assetId: string, orgId: string): Promise<TAssetUsage[]> {
|
||||
try {
|
||||
return await db
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TNewLessonCompletion,
|
||||
TNewLessonLanguage
|
||||
} from '@db/types';
|
||||
import { and, db, desc, eq, inArray, lt, sql } from '@db/drizzle';
|
||||
import { and, db, desc, eq, inArray, lt, ne, sql } from '@db/drizzle';
|
||||
|
||||
import type { DbOrTxClient } from '@db/drizzle';
|
||||
|
||||
@@ -53,6 +53,39 @@ export async function getLessonById(lessonId: string): Promise<LessonById | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface LessonNavInfo {
|
||||
id: string;
|
||||
courseId: string;
|
||||
lessonTitle: string;
|
||||
courseTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve navigation context (`/courses/{courseId}/lessons/{id}` + titles) for
|
||||
* a set of lesson ids in one query. Used to turn raw lesson ids into clickable
|
||||
* "go to lesson" links. Lessons in soft-deleted courses (`status = 'DELETED'`)
|
||||
* are excluded so callers don't link into a course that no longer exists.
|
||||
*/
|
||||
export async function getLessonNavInfoByIds(lessonIds: string[]): Promise<LessonNavInfo[]> {
|
||||
if (lessonIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await db
|
||||
.select({
|
||||
id: schema.lesson.id,
|
||||
courseId: schema.lesson.courseId,
|
||||
lessonTitle: schema.lesson.title,
|
||||
courseTitle: schema.course.title
|
||||
})
|
||||
.from(schema.lesson)
|
||||
.innerJoin(schema.course, eq(schema.course.id, schema.lesson.courseId))
|
||||
.where(and(inArray(schema.lesson.id, lessonIds), ne(schema.course.status, 'DELETED')));
|
||||
} catch (error) {
|
||||
console.error('getLessonNavInfoByIds error:', error);
|
||||
throw new Error(`Failed to get lesson nav info: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLessons(values: TNewLesson[]) {
|
||||
try {
|
||||
return db.insert(schema.lesson).values(values).returning();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { db } from '../drizzle';
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Rewrites stored media URLs from an old public bucket host to a new one.
|
||||
*
|
||||
* Only rows that contain the old host are touched, so the script is idempotent
|
||||
* and safe to re-run. The path after the host is left untouched, so the new
|
||||
* host must serve the same object keys (e.g. a custom domain pointed at the
|
||||
* same R2 bucket).
|
||||
*
|
||||
* Usage:
|
||||
* OLD_BUCKET_DOMAIN=https://pub-xxxx.r2.dev \
|
||||
* NEW_BUCKET_DOMAIN=https://img.cdn.example.com \
|
||||
* pnpm --filter @cio/db db:rewrite-media-host
|
||||
*
|
||||
* NEW_BUCKET_DOMAIN defaults to CLOUDFLARE_IMAGE_BUCKET_DOMAIN if unset.
|
||||
* The old host may also be passed as --from=<url> and the new as --to=<url>.
|
||||
*
|
||||
* Pass --dry-run to report how many rows would change per table without
|
||||
* writing anything.
|
||||
*/
|
||||
|
||||
function readArg(name: string): string | undefined {
|
||||
const prefix = `--${name}=`;
|
||||
const match = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
|
||||
return match?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function normalizeHost(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const oldDomainRaw = readArg('from') || process.env.OLD_BUCKET_DOMAIN;
|
||||
const newDomainRaw = readArg('to') || process.env.NEW_BUCKET_DOMAIN || process.env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN;
|
||||
|
||||
if (!oldDomainRaw || !newDomainRaw) {
|
||||
console.error(
|
||||
'Missing domains. Provide OLD_BUCKET_DOMAIN (or --from=) and NEW_BUCKET_DOMAIN/CLOUDFLARE_IMAGE_BUCKET_DOMAIN (or --to=).'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const OLD_HOST = normalizeHost(oldDomainRaw);
|
||||
const NEW_HOST = normalizeHost(newDomainRaw);
|
||||
|
||||
if (OLD_HOST === NEW_HOST) {
|
||||
console.error('Old and new hosts are identical; nothing to do.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
type Column = { name: string; isArray?: boolean };
|
||||
|
||||
// table name -> columns holding a full URL (dedicated columns + rich-text bodies)
|
||||
const TARGETS: Array<{ table: string; columns: Column[] }> = [
|
||||
{ table: 'user', columns: [{ name: 'image' }] },
|
||||
{ table: 'profile', columns: [{ name: 'avatar_url' }] },
|
||||
{ table: 'course', columns: [{ name: 'logo' }, { name: 'banner_image' }] },
|
||||
{
|
||||
table: 'assets',
|
||||
columns: [{ name: 'thumbnail_url' }, { name: 'thumbnail_candidates', isArray: true }]
|
||||
},
|
||||
{ table: 'organization', columns: [{ name: 'avatar_url' }, { name: 'favicon' }] },
|
||||
{ table: 'program', columns: [{ name: 'cover_image' }] },
|
||||
{ table: 'lesson_language', columns: [{ name: 'content' }] },
|
||||
{ table: 'course_newsfeed', columns: [{ name: 'content' }] },
|
||||
{ table: 'course_newsfeed_comment', columns: [{ name: 'content' }] },
|
||||
{ table: 'program_newsfeed', columns: [{ name: 'content' }] },
|
||||
{ table: 'program_newsfeed_comment', columns: [{ name: 'content' }] },
|
||||
{ table: 'community_question', columns: [{ name: 'body' }] }
|
||||
];
|
||||
|
||||
const LIKE_PATTERN = '%' + OLD_HOST + '%';
|
||||
|
||||
function columnMatch(column: Column): SQL {
|
||||
const id = sql.identifier(column.name);
|
||||
|
||||
if (column.isArray) {
|
||||
return sql`array_to_string(${id}, ',') LIKE ${LIKE_PATTERN}`;
|
||||
}
|
||||
|
||||
return sql`${id} LIKE ${LIKE_PATTERN}`;
|
||||
}
|
||||
|
||||
function columnReplace(column: Column): SQL {
|
||||
const id = sql.identifier(column.name);
|
||||
|
||||
if (column.isArray) {
|
||||
return sql`${id} = (
|
||||
SELECT coalesce(array_agg(replace(elem, ${OLD_HOST}, ${NEW_HOST}) ORDER BY ord), '{}'::text[])
|
||||
FROM unnest(${id}) WITH ORDINALITY AS t(elem, ord)
|
||||
)`;
|
||||
}
|
||||
|
||||
return sql`${id} = replace(${id}, ${OLD_HOST}, ${NEW_HOST})`;
|
||||
}
|
||||
|
||||
function matchClause(columns: Column[]): SQL {
|
||||
return sql.join(
|
||||
columns.map((column) => columnMatch(column)),
|
||||
sql` OR `
|
||||
);
|
||||
}
|
||||
|
||||
async function dryRun_() {
|
||||
console.log(`DRY RUN — no changes will be made.\n from: ${OLD_HOST}\n to: ${NEW_HOST}\n`);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
let total = 0;
|
||||
|
||||
for (const { table, columns } of TARGETS) {
|
||||
const result = await db.execute(
|
||||
sql`SELECT count(*)::int AS count FROM ${sql.identifier(table)} WHERE ${matchClause(columns)}`
|
||||
);
|
||||
const rows = result as unknown as Array<{ count: number }>;
|
||||
const count = rows[0]?.count ?? 0;
|
||||
|
||||
counts[`${table} (${columns.map((column) => column.name).join(', ')})`] = count;
|
||||
total += count;
|
||||
}
|
||||
|
||||
console.log('Rows that WOULD be updated per table/column:');
|
||||
console.table(counts);
|
||||
console.log(`Total rows that would change: ${total}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function applyRewrite() {
|
||||
console.log(`Rewriting media host:\n from: ${OLD_HOST}\n to: ${NEW_HOST}\n`);
|
||||
|
||||
const totals = await db.transaction(async (tx) => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const { table, columns } of TARGETS) {
|
||||
const setClause = sql.join(
|
||||
columns.map((column) => columnReplace(column)),
|
||||
sql`, `
|
||||
);
|
||||
|
||||
const result = await tx.execute(
|
||||
sql`UPDATE ${sql.identifier(table)} SET ${setClause} WHERE ${matchClause(columns)}`
|
||||
);
|
||||
|
||||
// postgres-js returns affected-row count on `.count` for non-returning queries
|
||||
counts[table] = (result as unknown as { count?: number }).count ?? 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
console.log('Rewrite completed. Rows updated per table:');
|
||||
console.table(totals);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function rewriteMediaHost() {
|
||||
try {
|
||||
if (dryRun) {
|
||||
await dryRun_();
|
||||
return;
|
||||
}
|
||||
|
||||
await applyRewrite();
|
||||
} catch (error) {
|
||||
console.error('rewriteMediaHost failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
rewriteMediaHost();
|
||||
@@ -2,3 +2,4 @@ export * from './media';
|
||||
export * from './emails';
|
||||
export * from './agent-course-generation';
|
||||
export * from './notifications';
|
||||
export * from './maintenance';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { JOB_NAMES, QUEUE_NAMES } from '../queues/names';
|
||||
import { QUEUE_DEFAULTS } from '../queues/defaults';
|
||||
import { getQueue } from '../queues/factories';
|
||||
import type { TAssetStorageCleanupPayload } from '../payloads/maintenance';
|
||||
|
||||
/**
|
||||
* Enqueue a background sweep of a deleted asset's object-storage files. The
|
||||
* payload carries the concrete buckets/keys/prefixes because the asset row is
|
||||
* already deleted by the time this runs.
|
||||
*/
|
||||
export async function enqueueAssetStorageCleanup(payload: TAssetStorageCleanupPayload): Promise<string> {
|
||||
const job = await getQueue(QUEUE_NAMES.maintenance).add(
|
||||
JOB_NAMES.maintenance.assetStorageCleanup,
|
||||
payload,
|
||||
QUEUE_DEFAULTS[QUEUE_NAMES.maintenance]
|
||||
);
|
||||
|
||||
return job.id ?? '';
|
||||
}
|
||||
@@ -36,3 +36,22 @@ export const ZMediaJobReapPayload = z.object({
|
||||
staleAfterMinutes: z.number().int().positive().default(30)
|
||||
});
|
||||
export type TMediaJobReapPayload = z.infer<typeof ZMediaJobReapPayload>;
|
||||
|
||||
const ZBucketKey = z.object({ bucket: z.string().min(1), key: z.string().min(1) });
|
||||
const ZBucketPrefix = z.object({ bucket: z.string().min(1), prefix: z.string().min(1) });
|
||||
|
||||
/**
|
||||
* Purge every object backing a deleted asset from object storage. The asset
|
||||
* DB row (and its cascaded usages/jobs/transcript rows) is already gone by the
|
||||
* time this runs, so the producer captures the concrete keys/prefixes up front
|
||||
* and passes them here. Deletion is idempotent — missing objects are fine.
|
||||
*/
|
||||
export const ZAssetStorageCleanupPayload = z.object({
|
||||
assetId: z.string().min(1),
|
||||
organizationId: z.string().min(1),
|
||||
/** Whole object trees to sweep (e.g. HLS `{assetId}/`, `thumbnails/{assetId}/`). */
|
||||
prefixes: z.array(ZBucketPrefix).default([]),
|
||||
/** Individual objects (e.g. raw upload, transcript VTT). */
|
||||
keys: z.array(ZBucketKey).default([])
|
||||
});
|
||||
export type TAssetStorageCleanupPayload = z.infer<typeof ZAssetStorageCleanupPayload>;
|
||||
|
||||
@@ -48,6 +48,7 @@ export const JOB_NAMES = {
|
||||
retentionCompact: 'retention-compact',
|
||||
deadLetterCleanup: 'dead-letter-cleanup',
|
||||
mediaJobReap: 'media-job-reap',
|
||||
analyticsDailyRollup: 'analytics-daily-rollup'
|
||||
analyticsDailyRollup: 'analytics-daily-rollup',
|
||||
assetStorageCleanup: 'asset-storage-cleanup'
|
||||
}
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user