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:
Rotimi Best
2026-06-28 12:36:13 +01:00
parent 2ccbf30414
commit 08ea608f94
31 changed files with 966 additions and 80 deletions
+1
View File
@@ -325,6 +325,7 @@ Use `.server.ts` files for server-side code to isolate API keys.
- For dashboard forms, prefer `@cio/ui/custom/*-field` wrappers (for example `InputField`, `TextareaField`, `CheckboxField`) so label/error/spacing behavior stays consistent.
- Use base primitives (`@cio/ui/base/input`, `@cio/ui/base/textarea`, `@cio/ui/base/checkbox`, `@cio/ui/base/label`) only when creating/updating reusable UI components or when no custom field wrapper exists.
- In app-level form UIs, do not introduce native form controls (`<input>`, `<textarea>`, `<label>`) when equivalent `packages/ui` components exist.
- **Icon-only buttons** (a `Button` whose content is just an icon, e.g. `size="icon"`) must use `variant="secondary"`.
- **Theme color classes:** Classes that use colors from `packages/ui/src/index.css` (e.g. `text-muted-foreground`, `text-primary`) must be prefixed with `ui:` in dashboard code so they resolve against the UI theme (e.g. `ui:text-muted-foreground`, `ui:text-primary`). Only color-related utilities need the prefix; layout/sizing classes like `rounded`, `border`, `p-4` stay unprefixed (Tailwind defaults).
## Best Practices Summary
@@ -6,6 +6,7 @@ import type {
AssetUsage,
AssetUsageGraph,
CreateAssetRequest,
DeleteAssetRequest,
GetAssetTranscriptRequest,
GetAssetUsageRequest,
GetYouTubeMetadataRequest,
@@ -273,6 +274,30 @@ export class MediaApi extends BaseApiWithErrors {
return success;
}
/**
* Permanently delete an asset and its stored files. The API blocks deletion
* while the asset is still attached anywhere (409 ASSET_IN_USE). Returns true
* only when the asset was actually removed.
*/
async deleteAsset(assetId: string): Promise<boolean> {
let deleted = false;
await this.execute<DeleteAssetRequest>({
requestFn: () => classroomio.organization.assets[':assetId'].$delete({ param: { assetId } }),
logContext: 'deleting media asset',
onSuccess: () => {
deleted = true;
this.assets = this.assets.filter((asset) => asset.id !== assetId);
snackbar.success('snackbar.media_manager.delete_success');
},
onError: (error) => {
const inUse = typeof error === 'object' && error !== null && 'code' in error && error.code === 'ASSET_IN_USE';
snackbar.error(inUse ? 'snackbar.media_manager.delete_in_use' : 'snackbar.media_manager.delete_failed');
}
});
return deleted;
}
async getVideoPlaybackUrl(asset: OrganizationAsset): Promise<string | null> {
return this.getAssetDownloadUrl(asset, { forPlayback: true });
}
@@ -10,6 +10,7 @@
import ImageIcon from '@lucide/svelte/icons/image';
import LinkIcon from '@lucide/svelte/icons/link';
import PencilIcon from '@lucide/svelte/icons/pencil';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { t } from '$lib/utils/functions/translations';
import {
@@ -27,6 +28,7 @@
onUsage?: (asset: OrganizationAsset) => void;
onDownload?: (asset: OrganizationAsset) => void;
onManageThumbnails?: (asset: OrganizationAsset) => void;
onDelete?: (asset: OrganizationAsset) => void;
}
let {
@@ -35,7 +37,8 @@
onEdit = () => {},
onUsage = () => {},
onDownload = () => {},
onManageThumbnails = () => {}
onManageThumbnails = () => {},
onDelete = () => {}
}: Props = $props();
</script>
@@ -87,6 +90,13 @@
{$t('media_manager.actions.download')}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => onDelete(asset)}>
<span class="flex items-center gap-2">
<Trash2Icon size={14} />
{$t('media_manager.actions.delete')}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1,15 +1,20 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import * as Item from '@cio/ui/base/item';
import { Spinner } from '@cio/ui/base/spinner';
import * as Tooltip from '@cio/ui/base/tooltip';
import { Empty } from '@cio/ui/custom/empty';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AssetUsageList from './asset-usage-list.svelte';
import { t } from '$lib/utils/functions/translations';
import {
formatBytes,
formatUsageDate,
getAssetDisplayName,
getKindIcon,
getTargetTypeLabel,
type AssetUsageGraph,
type OrganizationAsset
} from '$features/media/utils';
@@ -19,6 +24,7 @@
selectedAsset?: OrganizationAsset | null;
usageData?: AssetUsageGraph | null;
isLoading?: boolean;
onRefresh?: () => void;
onOpenChange?: (open: boolean) => void;
}
@@ -27,6 +33,7 @@
selectedAsset = null,
usageData = null,
isLoading = false,
onRefresh = () => {},
onOpenChange = () => {}
}: Props = $props();
@@ -96,42 +103,37 @@
{/if}
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.loading')}</p>
<Empty description={$t('media_manager.usage.loading')} icon={Spinner} class="ui:max-h-40 py-6" />
{:else if isUsageEmpty}
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.empty')}</p>
{:else if usageData}
<p class="ui:text-muted-foreground mb-3 text-sm">
{$t('media_manager.usage.total')}
{usageData.usageCount}
</p>
<div class="max-h-[420px] overflow-auto rounded-lg border">
<div class="divide-y">
{#each usageData.usages as usage (usage.id)}
<div class="grid grid-cols-1 gap-2 p-3 text-sm md:grid-cols-5 md:items-center md:gap-4">
<div>
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_type')}</p>
<p>{getTargetTypeLabel(usage.targetType)}</p>
</div>
<div>
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_id')}</p>
<p class="font-mono text-xs">{usage.targetId}</p>
</div>
<div>
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.slot')}</p>
<p>{usage.slotType}{usage.slotKey ? ` (${usage.slotKey})` : ''}</p>
</div>
<div>
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.position')}</p>
<p>{usage.position ?? t.get('media_manager.common.not_available')}</p>
</div>
<div>
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.added')}</p>
<p>{formatUsageDate(usage.createdAt)}</p>
</div>
</div>
{/each}
</div>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="ui:text-muted-foreground text-sm">
{$t('media_manager.usage.total')}
{usageData.usageCount}
</p>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="secondary"
size="icon"
onclick={() => onRefresh()}
aria-label={$t('media_manager.usage.refresh')}
>
<RefreshCwIcon size={16} />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4}>
{$t('media_manager.usage.refresh')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<AssetUsageList usages={usageData.usages} onNavigate={() => handleOpenChange(false)} />
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,54 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { resolve } from '$app/paths';
import { t } from '$lib/utils/functions/translations';
import { formatUsageDate, getSlotTypeLabel, getTargetTypeLabel, type AssetUsageGraph } from '$features/media/utils';
interface Props {
usages: AssetUsageGraph['usages'];
onNavigate?: () => void;
}
let { usages, onNavigate = () => {} }: Props = $props();
function lessonHref(courseId: string, lessonId: string) {
return resolve(`/courses/${courseId}/lessons/${lessonId}`, {});
}
</script>
<div class="max-h-[420px] overflow-auto rounded-lg border">
<div class="divide-y">
{#each usages as usage (usage.id)}
<div class="flex flex-col gap-2 p-3 text-sm md:flex-row md:items-center md:justify-between md:gap-4">
<div class="min-w-0 flex-1">
<p class="truncate font-medium">
{usage.targetTitle ?? getTargetTypeLabel(usage.targetType)}
</p>
<p class="ui:text-muted-foreground mt-0.5 truncate text-xs">
{#if usage.courseTitle}
{usage.courseTitle} ·
{/if}
{getSlotTypeLabel(usage.slotType)}{usage.slotKey ? ` (${usage.slotKey})` : ''} ·
{$t('media_manager.usage.table.added')}
{formatUsageDate(usage.createdAt)}
</p>
</div>
{#if usage.targetType === 'lesson' && usage.courseId}
<Button
variant="outline"
size="sm"
class="shrink-0"
href={lessonHref(usage.courseId!, usage.targetId)}
onclick={() => onNavigate()}
>
<ExternalLinkIcon size={14} class="mr-1.5" />
{$t('media_manager.usage.go_to_lesson')}
</Button>
{/if}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,117 @@
<script lang="ts">
import * as Alert from '@cio/ui/base/alert';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import * as Tooltip from '@cio/ui/base/tooltip';
import { Empty } from '@cio/ui/custom/empty';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
import AssetUsageList from './asset-usage-list.svelte';
import { t } from '$lib/utils/functions/translations';
import { getAssetDisplayName, type AssetUsageGraph, type OrganizationAsset } from '$features/media/utils';
interface Props {
open?: boolean;
asset?: OrganizationAsset | null;
usageData?: AssetUsageGraph | null;
isLoadingUsage?: boolean;
isDeleting?: boolean;
onConfirm?: () => void;
onRefresh?: () => void;
onOpenChange?: (open: boolean) => void;
}
let {
open = $bindable(false),
asset = null,
usageData = null,
isLoadingUsage = false,
isDeleting = false,
onConfirm = () => {},
onRefresh = () => {},
onOpenChange = () => {}
}: Props = $props();
const isInUse = $derived((usageData?.usageCount ?? 0) > 0);
function handleOpenChange(isOpen: boolean) {
open = isOpen;
if (!isOpen) {
onOpenChange(false);
}
}
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => handleOpenChange(isOpen)}>
<Dialog.Content class="w-[95%] max-w-2xl">
<Dialog.Header>
<Dialog.Title>{$t('media_manager.delete.title')}</Dialog.Title>
<Dialog.Description>
{#if asset}
<span class="font-medium">{getAssetDisplayName(asset)}</span>
{/if}
</Dialog.Description>
</Dialog.Header>
{#if isLoadingUsage}
<Empty description={$t('media_manager.usage.loading')} icon={Spinner} class="ui:max-h-40 py-6" />
{:else if isInUse}
<Alert.Root variant="warning">
<TriangleAlertIcon />
<Alert.Title>{$t('media_manager.delete.in_use_title')}</Alert.Title>
<Alert.Description>{$t('media_manager.delete.in_use_description')}</Alert.Description>
</Alert.Root>
<div class="mt-2 mb-1 flex items-center justify-between gap-2">
<p class="ui:text-muted-foreground text-sm">
{$t('media_manager.usage.total')}
{usageData?.usageCount}
</p>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="secondary"
size="icon"
onclick={() => onRefresh()}
aria-label={$t('media_manager.usage.refresh')}
>
<RefreshCwIcon size={16} />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={4}>
{$t('media_manager.usage.refresh')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<AssetUsageList usages={usageData?.usages ?? []} onNavigate={() => handleOpenChange(false)} />
<Dialog.Footer>
<Button variant="outline" onclick={() => handleOpenChange(false)}>
{$t('media_manager.delete.close')}
</Button>
</Dialog.Footer>
{:else}
<Alert.Root variant="destructive">
<TriangleAlertIcon />
<Alert.Title>{$t('media_manager.delete.irreversible_title')}</Alert.Title>
<Alert.Description>{$t('media_manager.delete.irreversible_description')}</Alert.Description>
</Alert.Root>
<Dialog.Footer>
<Button variant="outline" disabled={isDeleting} onclick={() => handleOpenChange(false)}>
{$t('media_manager.delete.cancel')}
</Button>
<Button variant="destructive" disabled={isDeleting} onclick={onConfirm}>
{$t('media_manager.delete.confirm')}
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -1,5 +1,7 @@
export { default as AssetCard } from './asset-card.svelte';
export { default as AssetUsageDialog } from './asset-usage-dialog.svelte';
export { default as AssetUsageList } from './asset-usage-list.svelte';
export { default as DeleteAssetDialog } from './delete-asset-dialog.svelte';
export { default as EditAssetDialog } from './edit-asset-dialog.svelte';
export { default as ManageThumbnailsDialog } from './manage-thumbnails-dialog.svelte';
export { default as MediaFilters } from './media-filters.svelte';
@@ -10,6 +10,7 @@
import {
AssetCard,
AssetUsageDialog,
DeleteAssetDialog,
EditAssetDialog,
ManageThumbnailsDialog,
MediaFilters,
@@ -36,8 +37,10 @@
let editOpen = $state(false);
let usageOpen = $state(false);
let manageThumbsOpen = $state(false);
let deleteOpen = $state(false);
let isSavingAsset = $state(false);
let isUsageLoading = $state(false);
let isDeleting = $state(false);
let downloadingAssetId = $state<string | null>(null);
let selectedAsset = $state<OrganizationAsset | null>(null);
let usageData = $state<AssetUsageGraph | null>(null);
@@ -96,11 +99,8 @@
}
}
async function openUsage(asset: OrganizationAsset) {
selectedAsset = asset;
usageOpen = true;
async function loadUsage(asset: OrganizationAsset) {
isUsageLoading = true;
usageData = null;
try {
usageData = await mediaApi.getAssetUsage(asset.id);
} finally {
@@ -108,12 +108,53 @@
}
}
async function openUsage(asset: OrganizationAsset) {
selectedAsset = asset;
usageOpen = true;
usageData = null;
await loadUsage(asset);
}
async function refreshUsage() {
if (!selectedAsset) return;
await loadUsage(selectedAsset);
}
function resetUsageModalState() {
selectedAsset = null;
usageData = null;
isUsageLoading = false;
}
async function openDelete(asset: OrganizationAsset) {
selectedAsset = asset;
deleteOpen = true;
usageData = null;
await loadUsage(asset);
}
async function confirmDelete() {
if (!selectedAsset) return;
isDeleting = true;
try {
const deleted = await mediaApi.deleteAsset(selectedAsset.id);
if (!deleted) return;
deleteOpen = false;
await refreshStorageSummary();
} finally {
isDeleting = false;
}
}
function handleDeleteOpenChange(isOpen: boolean) {
if (!isOpen) {
resetUsageModalState();
}
}
async function downloadAsset(asset: OrganizationAsset) {
downloadingAssetId = asset.id;
try {
@@ -168,6 +209,7 @@
onUsage={openUsage}
onDownload={downloadAsset}
onManageThumbnails={openManageThumbnails}
onDelete={openDelete}
/>
{/each}
</Item.Group>
@@ -208,5 +250,17 @@
{selectedAsset}
{usageData}
isLoading={isUsageLoading}
onRefresh={refreshUsage}
onOpenChange={handleUsageOpenChange}
/>
<DeleteAssetDialog
bind:open={deleteOpen}
asset={selectedAsset}
{usageData}
isLoadingUsage={isUsageLoading}
{isDeleting}
onConfirm={confirmDelete}
onRefresh={refreshUsage}
onOpenChange={handleDeleteOpenChange}
/>
@@ -110,6 +110,14 @@ export function getTargetTypeLabel(targetType: string): string {
return t.get(keyMap[targetType] ?? 'media_manager.usage.target.unknown');
}
export function getSlotTypeLabel(slotType: string): string {
const keyMap: Record<string, string> = {
lesson_video: 'media_manager.usage.slot_type.lesson_video',
lesson_document: 'media_manager.usage.slot_type.lesson_document'
};
return t.get(keyMap[slotType] ?? 'media_manager.usage.slot_type.unknown');
}
export function formatUsageDate(value: string | null | undefined): string {
if (!value) {
return t.get('media_manager.common.not_available');
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder",
"transcript_saved": "Udskrift opdateret",
"transcript_save_failed": "Kunne ikke gemme transskription",
"transcription_already_running": "Et transskriptionsjob er allerede i gang for denne video"
"transcription_already_running": "Et transskriptionsjob er allerede i gang for denne video",
"video_added_to_lesson": "Video tilføjet til lektion",
"delete_success": "Video slettet",
"delete_in_use": "Denne video er stadig knyttet til en lektion. Fjern det der først.",
"delete_failed": "Videoen kunne ikke slettes"
},
"tags": {
"group_created": "Taggruppe oprettet",
@@ -2808,7 +2812,14 @@
"exercise": "Motion",
"question": "Spørgsmål",
"unknown": "Ukendt"
}
},
"go_to_lesson": "Gå til lektion",
"slot_type": {
"lesson_video": "Lektion video",
"lesson_document": "Lektionsdokument",
"unknown": "Vedhæftet fil"
},
"refresh": "Opdater brugen"
},
"form": {
"file_name": "Filnavn",
@@ -2838,7 +2849,8 @@
"edit": "Rediger",
"where_used": "Hvor brugt",
"download": "Download",
"manage_thumbnails": "Administrer thumbnails"
"manage_thumbnails": "Administrer thumbnails",
"delete": "Slet"
},
"table": {
"title": "Titel",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Forhåndsvisning af tilpasset miniaturebillede",
"regenerate": "Regenerer",
"save_selection": "Gem thumbnail"
},
"delete": {
"title": "Slet video",
"irreversible_title": "Dette kan ikke fortrydes",
"irreversible_description": "Denne video og alle dens filer – gengivelser, miniaturebilleder og billedtekster – slettes permanent og kan ikke gendannes.",
"in_use_title": "Stadig i brug",
"in_use_description": "Denne video er vedhæftet nedenstående lektioner. Åbn hver lektion, fjern videoen der, og kom så tilbage for at slette den.",
"confirm": "Slet permanent",
"cancel": "Annuller",
"close": "Luk"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden",
"transcript_saved": "Transkript aktualisiert",
"transcript_save_failed": "Das Transkript konnte nicht gespeichert werden",
"transcription_already_running": "Für dieses Video wird bereits ein Transkriptionsauftrag ausgeführt"
"transcription_already_running": "Für dieses Video wird bereits ein Transkriptionsauftrag ausgeführt",
"video_added_to_lesson": "Video zur Lektion hinzugefügt",
"delete_success": "Video gelöscht",
"delete_in_use": "Dieses Video ist noch einer Lektion beigefügt. Entfernen Sie es zuerst dort.",
"delete_failed": "Video konnte nicht gelöscht werden"
},
"tags": {
"group_created": "Tag-Gruppe erstellt",
@@ -2808,7 +2812,14 @@
"exercise": "Übung",
"question": "Frage",
"unknown": "Unbekannt"
}
},
"go_to_lesson": "Gehe zur Lektion",
"slot_type": {
"lesson_video": "Unterrichtsvideo",
"lesson_document": "Unterrichtsdokument",
"unknown": "Anhang"
},
"refresh": "Nutzung aktualisieren"
},
"form": {
"file_name": "Dateiname",
@@ -2838,7 +2849,8 @@
"edit": "Bearbeiten",
"where_used": "Wo verwendet",
"download": "Herunterladen",
"manage_thumbnails": "Miniaturansichten verwalten"
"manage_thumbnails": "Miniaturansichten verwalten",
"delete": "Löschen"
},
"table": {
"title": "Titel",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Benutzerdefinierte Miniaturvorschau",
"regenerate": "Regenerieren",
"save_selection": "Miniaturansicht speichern"
},
"delete": {
"title": "Video löschen",
"irreversible_title": "Dies kann nicht rückgängig gemacht werden",
"irreversible_description": "Dieses Video und alle seine Dateien – Wiedergaben, Miniaturansichten und Untertitel – werden dauerhaft gelöscht und können nicht wiederhergestellt werden.",
"in_use_title": "Immer noch im Einsatz",
"in_use_description": "Dieses Video ist den Lektionen unten beigefügt. Öffnen Sie jede Lektion, entfernen Sie das Video dort und kehren Sie dann zurück, um es zu löschen.",
"confirm": "Dauerhaft löschen",
"cancel": "Abbrechen",
"close": "Schließen"
}
},
"settings": {
@@ -1081,7 +1081,18 @@
"edit": "Edit",
"where_used": "Where used",
"download": "Download",
"manage_thumbnails": "Manage thumbnails"
"manage_thumbnails": "Manage thumbnails",
"delete": "Delete"
},
"delete": {
"title": "Delete video",
"irreversible_title": "This cannot be undone",
"irreversible_description": "This video and all of its files — renditions, thumbnails, and captions — will be permanently deleted and cannot be recovered.",
"in_use_title": "Still in use",
"in_use_description": "This video is attached to the lessons below. Open each lesson, remove the video there, then come back to delete it.",
"confirm": "Delete permanently",
"cancel": "Cancel",
"close": "Close"
},
"pagination": {
"previous": "Previous",
@@ -1128,12 +1139,19 @@
"loading": "Loading usage details...",
"empty": "This asset is not attached anywhere yet.",
"total": "Total usages:",
"go_to_lesson": "Go to lesson",
"refresh": "Refresh usage",
"target": {
"lesson": "Lesson",
"exercise": "Exercise",
"question": "Question",
"unknown": "Unknown"
},
"slot_type": {
"lesson_video": "Lesson video",
"lesson_document": "Lesson document",
"unknown": "Attachment"
},
"table": {
"target_type": "Target Type",
"target_id": "Target ID",
@@ -4121,6 +4139,9 @@
"update_failed": "Failed to update media asset",
"update_success": "Media asset updated",
"usage_failed": "Failed to load asset usage",
"delete_success": "Video deleted",
"delete_in_use": "This video is still attached to a lesson. Remove it there first.",
"delete_failed": "Failed to delete video",
"download_failed": "Failed to get asset download URL",
"thumbnail_uploaded": "Thumbnail uploaded",
"thumbnail_upload_failed": "Failed to upload thumbnail",
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "No se pudieron regenerar las miniaturas",
"transcript_saved": "Transcripción actualizada",
"transcript_save_failed": "No se pudo guardar la transcripción",
"transcription_already_running": "Ya se está realizando un trabajo de transcripción para este vídeo."
"transcription_already_running": "Ya se está realizando un trabajo de transcripción para este vídeo.",
"video_added_to_lesson": "Vídeo añadido a la lección.",
"delete_success": "Vídeo eliminado",
"delete_in_use": "Este vídeo todavía está adjunto a una lección. Quítelo allí primero.",
"delete_failed": "No se pudo eliminar el video"
},
"tags": {
"group_created": "Grupo de etiquetas creado",
@@ -2808,7 +2812,14 @@
"exercise": "Ejercicio",
"question": "Pregunta",
"unknown": "Desconocido"
}
},
"go_to_lesson": "ir a la lección",
"slot_type": {
"lesson_video": "Vídeo de la lección",
"lesson_document": "documento de lección",
"unknown": "Adjunto"
},
"refresh": "Actualizar uso"
},
"form": {
"file_name": "Nombre de archivo",
@@ -2838,7 +2849,8 @@
"edit": "Editar",
"where_used": "donde se usa",
"download": "Descargar",
"manage_thumbnails": "Administrar miniaturas"
"manage_thumbnails": "Administrar miniaturas",
"delete": "Eliminar"
},
"table": {
"title": "Título",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Vista previa en miniatura personalizada",
"regenerate": "regenerar",
"save_selection": "Guardar miniatura"
},
"delete": {
"title": "Eliminar vídeo",
"irreversible_title": "Esto no se puede deshacer",
"irreversible_description": "Este video y todos sus archivos (representaciones, miniaturas y subtítulos) se eliminarán permanentemente y no se podrán recuperar.",
"in_use_title": "Todavía en uso",
"in_use_description": "Este vídeo se adjunta a las lecciones siguientes. Abra cada lección, elimine el video allí y luego regrese para eliminarlo.",
"confirm": "Eliminar permanentemente",
"cancel": "Cancelar",
"close": "Cerrar"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Échec de la régénération des miniatures",
"transcript_saved": "Transcription mise à jour",
"transcript_save_failed": "Impossible d'enregistrer la transcription",
"transcription_already_running": "Un travail de transcription est déjà en cours pour cette vidéo"
"transcription_already_running": "Un travail de transcription est déjà en cours pour cette vidéo",
"video_added_to_lesson": "Vidéo ajoutée à la leçon",
"delete_success": "Vidéo supprimée",
"delete_in_use": "Cette vidéo est toujours jointe à une leçon. Retirez-le d'abord.",
"delete_failed": "Échec de la suppression de la vidéo"
},
"tags": {
"group_created": "Groupe de balises créé",
@@ -2808,7 +2812,14 @@
"exercise": "Exercice",
"question": "Question",
"unknown": "Inconnu"
}
},
"go_to_lesson": "Aller à la leçon",
"slot_type": {
"lesson_video": "Vidéo de cours",
"lesson_document": "Document de cours",
"unknown": "Pièce jointe"
},
"refresh": "Actualiser l'utilisation"
},
"form": {
"file_name": "Nom du fichier",
@@ -2838,7 +2849,8 @@
"edit": "Modifier",
"where_used": "Où utilisé",
"download": "Télécharger",
"manage_thumbnails": "Gérer les vignettes"
"manage_thumbnails": "Gérer les vignettes",
"delete": "Supprimer"
},
"table": {
"title": "Titre",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Aperçu miniature personnalisé",
"regenerate": "Régénérer",
"save_selection": "Enregistrer la vignette"
},
"delete": {
"title": "Supprimer la vidéo",
"irreversible_title": "Cela ne peut pas être annulé",
"irreversible_description": "Cette vidéo et tous ses fichiers (rendus, miniatures et sous-titres) seront définitivement supprimés et ne pourront pas être récupérés.",
"in_use_title": "Toujours utilisé",
"in_use_description": "Cette vidéo est jointe aux leçons ci-dessous. Ouvrez chaque leçon, supprimez-y la vidéo, puis revenez la supprimer.",
"confirm": "Supprimer définitivement",
"cancel": "Annuler",
"close": "Fermer"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल",
"transcript_saved": "प्रतिलेख अद्यतन किया गया",
"transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका",
"transcription_already_running": "इस वीडियो के लिए प्रतिलेखन कार्य पहले से ही प्रगति पर है"
"transcription_already_running": "इस वीडियो के लिए प्रतिलेखन कार्य पहले से ही प्रगति पर है",
"video_added_to_lesson": "पाठ में वीडियो जोड़ा गया",
"delete_success": "वीडियो हटा दिया गया",
"delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.",
"delete_failed": "वीडियो हटाने में विफल"
},
"tags": {
"group_created": "टैग समूह बनाया गया",
@@ -2808,7 +2812,14 @@
"exercise": "व्यायाम",
"question": "प्रश्न",
"unknown": "अज्ञात"
}
},
"go_to_lesson": "पाठ पर जाएँ",
"slot_type": {
"lesson_video": "पाठ वीडियो",
"lesson_document": "पाठ दस्तावेज़",
"unknown": "लगाव"
},
"refresh": "उपयोग ताज़ा करें"
},
"form": {
"file_name": "फ़ाइल का नाम",
@@ -2838,7 +2849,8 @@
"edit": "संपादित करें",
"where_used": "कहां उपयोग किया जाता है",
"download": "डाउनलोड करें",
"manage_thumbnails": "थंबनेल प्रबंधित करें"
"manage_thumbnails": "थंबनेल प्रबंधित करें",
"delete": "हटाएँ"
},
"table": {
"title": "शीर्षक",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "कस्टम थंबनेल पूर्वावलोकन",
"regenerate": "पुनर्जीवित करें",
"save_selection": "थंबनेल सहेजें"
},
"delete": {
"title": "वीडियो हटाएँ",
"irreversible_title": "इसे पूर्ववत नहीं किया जा सकता",
"irreversible_description": "यह वीडियो और इसकी सभी फ़ाइलें - प्रतिपादन, थंबनेल और कैप्शन - स्थायी रूप से हटा दिए जाएंगे और पुनर्प्राप्त नहीं किए जा सकेंगे।",
"in_use_title": "अभी भी उपयोग में है",
"in_use_description": "यह वीडियो नीचे दिए गए पाठों से जुड़ा हुआ है। प्रत्येक पाठ खोलें, वहां से वीडियो हटाएं, फिर उसे हटाने के लिए वापस आएं।",
"confirm": "स्थायी रूप से हटाएँ",
"cancel": "रद्द करें",
"close": "बंद करें"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur",
"transcript_saved": "Transkrypcja zaktualizowana",
"transcript_save_failed": "Nie udało się zapisać transkrypcji",
"transcription_already_running": "Zadanie transkrypcji tego filmu wideo jest już w toku"
"transcription_already_running": "Zadanie transkrypcji tego filmu wideo jest już w toku",
"video_added_to_lesson": "Film dodany do lekcji",
"delete_success": "Film usunięty",
"delete_in_use": "Ten film jest nadal dołączony do lekcji. Najpierw usuń go tam.",
"delete_failed": "Nie udało się usunąć filmu"
},
"tags": {
"group_created": "Utworzono grupę tagów",
@@ -2808,7 +2812,14 @@
"exercise": "Ćwicz",
"question": "Pytanie",
"unknown": "Nieznany"
}
},
"go_to_lesson": "Idź na lekcję",
"slot_type": {
"lesson_video": "Film z lekcją",
"lesson_document": "Dokument lekcji",
"unknown": "Załącznik"
},
"refresh": "Odśwież użycie"
},
"form": {
"file_name": "Nazwa pliku",
@@ -2838,7 +2849,8 @@
"edit": "Edytuj",
"where_used": "Gdzie używany",
"download": "Pobierz",
"manage_thumbnails": "Zarządzaj miniaturami"
"manage_thumbnails": "Zarządzaj miniaturami",
"delete": "Usuń"
},
"table": {
"title": "Tytuł",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Niestandardowy podgląd miniatur",
"regenerate": "Zregeneruj się",
"save_selection": "Zapisz miniaturę"
},
"delete": {
"title": "Usuń wideo",
"irreversible_title": "Tego nie można cofnąć",
"irreversible_description": "Ten film wideo i wszystkie jego pliki — wersje, miniatury i podpisy — zostaną trwale usunięte i nie będzie można ich odzyskać.",
"in_use_title": "Nadal w użyciu",
"in_use_description": "Ten film jest dołączony do poniższych lekcji. Otwórz każdą lekcję, usuń stamtąd wideo, a następnie wróć, aby je usunąć.",
"confirm": "Usuń trwale",
"cancel": "Anuluj",
"close": "Zamknij"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Falha ao regenerar miniaturas",
"transcript_saved": "Transcrição atualizada",
"transcript_save_failed": "Não foi possível salvar a transcrição",
"transcription_already_running": "Um trabalho de transcrição já está em andamento para este vídeo"
"transcription_already_running": "Um trabalho de transcrição já está em andamento para este vídeo",
"video_added_to_lesson": "Vídeo adicionado à aula",
"delete_success": "Vídeo excluído",
"delete_in_use": "Este vídeo ainda está anexado a uma aula. Remova-o primeiro.",
"delete_failed": "Falha ao excluir vídeo"
},
"tags": {
"group_created": "Grupo de tags criado",
@@ -2808,7 +2812,14 @@
"exercise": "Exercício",
"question": "Pergunta",
"unknown": "Desconhecido"
}
},
"go_to_lesson": "Vá para a aula",
"slot_type": {
"lesson_video": "Vídeo da aula",
"lesson_document": "Documento da lição",
"unknown": "Anexo"
},
"refresh": "Atualizar uso"
},
"form": {
"file_name": "Nome do arquivo",
@@ -2838,7 +2849,8 @@
"edit": "Editar",
"where_used": "Onde usado",
"download": "Baixar",
"manage_thumbnails": "Gerenciar miniaturas"
"manage_thumbnails": "Gerenciar miniaturas",
"delete": "Excluir"
},
"table": {
"title": "Título",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Pré-visualização de miniaturas personalizadas",
"regenerate": "Regenerar",
"save_selection": "Salvar miniatura"
},
"delete": {
"title": "Excluir vídeo",
"irreversible_title": "Isso não pode ser desfeito",
"irreversible_description": "Este vídeo e todos os seus arquivos – representações, miniaturas e legendas – serão excluídos permanentemente e não poderão ser recuperados.",
"in_use_title": "Ainda em uso",
"in_use_description": "Este vídeo está anexado às aulas abaixo. Abra cada lição, remova o vídeo e volte para excluí-lo.",
"confirm": "Excluir permanentemente",
"cancel": "Cancelar",
"close": "Fechar"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Не удалось восстановить миниатюры.",
"transcript_saved": "Стенограмма обновлена",
"transcript_save_failed": "Не удалось сохранить расшифровку",
"transcription_already_running": "Транскрипция этого видео уже выполняется."
"transcription_already_running": "Транскрипция этого видео уже выполняется.",
"video_added_to_lesson": "Видео добавлено в урок",
"delete_success": "Видео удалено",
"delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.",
"delete_failed": "Не удалось удалить видео."
},
"tags": {
"group_created": "Группа тегов создана.",
@@ -2808,7 +2812,14 @@
"exercise": "Упражнение",
"question": "Вопрос",
"unknown": "Неизвестно"
}
},
"go_to_lesson": "Перейти на урок",
"slot_type": {
"lesson_video": "Видео урока",
"lesson_document": "Документ урока",
"unknown": "Приложение"
},
"refresh": "Обновить использование"
},
"form": {
"file_name": "Имя файла",
@@ -2838,7 +2849,8 @@
"edit": "Редактировать",
"where_used": "Где используется",
"download": "Скачать",
"manage_thumbnails": "Управление миниатюрами"
"manage_thumbnails": "Управление миниатюрами",
"delete": "Удалить"
},
"table": {
"title": "Название",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Пользовательский предварительный просмотр миниатюр",
"regenerate": "Регенерировать",
"save_selection": "Сохранить миниатюру"
},
"delete": {
"title": "Удалить видео",
"irreversible_title": "Это нельзя отменить",
"irreversible_description": "Это видео и все его файлы — версии, миниатюры и подписи — будут удалены без возможности восстановления без возможности восстановления.",
"in_use_title": "Все еще используется",
"in_use_description": "Это видео прикреплено к урокам ниже. Откройте каждый урок, удалите оттуда видео, затем вернитесь, чтобы удалить его.",
"confirm": "Удалить навсегда",
"cancel": "Отмена",
"close": "Закрыть"
}
},
"settings": {
@@ -357,7 +357,11 @@
"thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ",
"transcript_saved": "Đã cập nhật bản ghi",
"transcript_save_failed": "Không thể lưu bản ghi",
"transcription_already_running": "Công việc chép lời đang được thực hiện cho video này"
"transcription_already_running": "Công việc chép lời đang được thực hiện cho video này",
"video_added_to_lesson": "Video đã được thêm vào bài học",
"delete_success": "Đã xóa video",
"delete_in_use": "Video này vẫn được đính kèm với một bài học. Loại bỏ nó ở đó đầu tiên.",
"delete_failed": "Không thể xóa video"
},
"tags": {
"group_created": "Đã tạo nhóm thẻ",
@@ -2808,7 +2812,14 @@
"exercise": "tập thể dục",
"question": "Câu hỏi",
"unknown": "Không xác định"
}
},
"go_to_lesson": "Đi học",
"slot_type": {
"lesson_video": "Video bài học",
"lesson_document": "Tài liệu bài học",
"unknown": "Tệp đính kèm"
},
"refresh": "Làm mới mức sử dụng"
},
"form": {
"file_name": "Tên tệp",
@@ -2838,7 +2849,8 @@
"edit": "Chỉnh sửa",
"where_used": "Nơi sử dụng",
"download": "Tải xuống",
"manage_thumbnails": "Quản lý hình thu nhỏ"
"manage_thumbnails": "Quản lý hình thu nhỏ",
"delete": "Xóa"
},
"table": {
"title": "Tiêu đề",
@@ -2910,6 +2922,16 @@
"custom_preview_alt": "Xem trước hình thu nhỏ tùy chỉnh",
"regenerate": "tái sinh",
"save_selection": "Lưu hình thu nhỏ"
},
"delete": {
"title": "Xóa video",
"irreversible_title": "Việc này không thể hoàn tác được",
"irreversible_description": "Video này và tất cả các tệp của nó — bản trình diễn, hình thu nhỏ và chú thích — sẽ bị xóa vĩnh viễn và không thể khôi phục được.",
"in_use_title": "Vẫn đang được sử dụng",
"in_use_description": "Video này được đính kèm với các bài học dưới đây. Mở từng bài học ra, xóa video ở đó rồi quay lại xóa.",
"confirm": "Xóa vĩnh viễn",
"cancel": "Hủy bỏ",
"close": "Đóng"
}
},
"settings": {
+13
View File
@@ -3,11 +3,13 @@ import './../bootstrap';
import { Queue, Worker } from 'bullmq';
import { runAnalyticsRollupDaily } from '@cio/analytics';
import { purgeAssetStorage } from '@cio/core/services/assets/assets';
import { pruneDeadLetterJobsOlderThan, reapStuckMediaJobs } from '@cio/db/queries';
import {
JOB_NAMES,
QUEUE_NAMES,
ZAnalyticsDailyRollupPayload,
ZAssetStorageCleanupPayload,
ZDeadLetterCleanupPayload,
ZMediaJobReapPayload,
ZRetentionCompactPayload,
@@ -55,6 +57,17 @@ const worker = new Worker(
return { reaped: reaped.length };
}
if (job.name === JOB_NAMES.maintenance.assetStorageCleanup) {
const data = ZAssetStorageCleanupPayload.parse(job.data ?? {});
await purgeAssetStorage(data);
log.info('asset-storage-cleanup-done', {
assetId: data.assetId,
prefixes: data.prefixes.length,
keys: data.keys.length
});
return { assetId: data.assetId };
}
if (job.name === JOB_NAMES.maintenance.analyticsDailyRollup) {
const data = ZAnalyticsDailyRollupPayload.parse(job.data ?? {});
const result = await runAnalyticsRollupDaily({ daysAgo: data.daysAgo });
+99 -11
View File
@@ -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;
}
+16 -1
View File
@@ -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);
}
+1
View File
@@ -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",
+27
View File
@@ -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
+34 -1
View File
@@ -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();
+1
View File
@@ -2,3 +2,4 @@ export * from './media';
export * from './emails';
export * from './agent-course-generation';
export * from './notifications';
export * from './maintenance';
+19
View File
@@ -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 ?? '';
}
+19
View File
@@ -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>;
+2 -1
View File
@@ -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;