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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user