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:
@@ -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.
|
- 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.
|
- 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.
|
- 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).
|
- **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
|
## Best Practices Summary
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AssetUsage,
|
AssetUsage,
|
||||||
AssetUsageGraph,
|
AssetUsageGraph,
|
||||||
CreateAssetRequest,
|
CreateAssetRequest,
|
||||||
|
DeleteAssetRequest,
|
||||||
GetAssetTranscriptRequest,
|
GetAssetTranscriptRequest,
|
||||||
GetAssetUsageRequest,
|
GetAssetUsageRequest,
|
||||||
GetYouTubeMetadataRequest,
|
GetYouTubeMetadataRequest,
|
||||||
@@ -273,6 +274,30 @@ export class MediaApi extends BaseApiWithErrors {
|
|||||||
return success;
|
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> {
|
async getVideoPlaybackUrl(asset: OrganizationAsset): Promise<string | null> {
|
||||||
return this.getAssetDownloadUrl(asset, { forPlayback: true });
|
return this.getAssetDownloadUrl(asset, { forPlayback: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import ImageIcon from '@lucide/svelte/icons/image';
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
import LinkIcon from '@lucide/svelte/icons/link';
|
import LinkIcon from '@lucide/svelte/icons/link';
|
||||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
onUsage?: (asset: OrganizationAsset) => void;
|
onUsage?: (asset: OrganizationAsset) => void;
|
||||||
onDownload?: (asset: OrganizationAsset) => void;
|
onDownload?: (asset: OrganizationAsset) => void;
|
||||||
onManageThumbnails?: (asset: OrganizationAsset) => void;
|
onManageThumbnails?: (asset: OrganizationAsset) => void;
|
||||||
|
onDelete?: (asset: OrganizationAsset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -35,7 +37,8 @@
|
|||||||
onEdit = () => {},
|
onEdit = () => {},
|
||||||
onUsage = () => {},
|
onUsage = () => {},
|
||||||
onDownload = () => {},
|
onDownload = () => {},
|
||||||
onManageThumbnails = () => {}
|
onManageThumbnails = () => {},
|
||||||
|
onDelete = () => {}
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -87,6 +90,13 @@
|
|||||||
{$t('media_manager.actions.download')}
|
{$t('media_manager.actions.download')}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenu.Item>
|
</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.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Badge } from '@cio/ui/base/badge';
|
import { Badge } from '@cio/ui/base/badge';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
import * as Dialog from '@cio/ui/base/dialog';
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
import * as Item from '@cio/ui/base/item';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatUsageDate,
|
formatUsageDate,
|
||||||
getAssetDisplayName,
|
getAssetDisplayName,
|
||||||
getKindIcon,
|
getKindIcon,
|
||||||
getTargetTypeLabel,
|
|
||||||
type AssetUsageGraph,
|
type AssetUsageGraph,
|
||||||
type OrganizationAsset
|
type OrganizationAsset
|
||||||
} from '$features/media/utils';
|
} from '$features/media/utils';
|
||||||
@@ -19,6 +24,7 @@
|
|||||||
selectedAsset?: OrganizationAsset | null;
|
selectedAsset?: OrganizationAsset | null;
|
||||||
usageData?: AssetUsageGraph | null;
|
usageData?: AssetUsageGraph | null;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
selectedAsset = null,
|
selectedAsset = null,
|
||||||
usageData = null,
|
usageData = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
onRefresh = () => {},
|
||||||
onOpenChange = () => {}
|
onOpenChange = () => {}
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -96,42 +103,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isLoading}
|
{#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}
|
{:else if isUsageEmpty}
|
||||||
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.empty')}</p>
|
<p class="ui:text-muted-foreground text-sm">{$t('media_manager.usage.empty')}</p>
|
||||||
{:else if usageData}
|
{:else if usageData}
|
||||||
<p class="ui:text-muted-foreground mb-3 text-sm">
|
<div class="mb-3 flex items-center justify-between gap-2">
|
||||||
{$t('media_manager.usage.total')}
|
<p class="ui:text-muted-foreground text-sm">
|
||||||
{usageData.usageCount}
|
{$t('media_manager.usage.total')}
|
||||||
</p>
|
{usageData.usageCount}
|
||||||
<div class="max-h-[420px] overflow-auto rounded-lg border">
|
</p>
|
||||||
<div class="divide-y">
|
<Tooltip.Provider>
|
||||||
{#each usageData.usages as usage (usage.id)}
|
<Tooltip.Root>
|
||||||
<div class="grid grid-cols-1 gap-2 p-3 text-sm md:grid-cols-5 md:items-center md:gap-4">
|
<Tooltip.Trigger>
|
||||||
<div>
|
{#snippet child({ props })}
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_type')}</p>
|
<Button
|
||||||
<p>{getTargetTypeLabel(usage.targetType)}</p>
|
{...props}
|
||||||
</div>
|
variant="secondary"
|
||||||
<div>
|
size="icon"
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.target_id')}</p>
|
onclick={() => onRefresh()}
|
||||||
<p class="font-mono text-xs">{usage.targetId}</p>
|
aria-label={$t('media_manager.usage.refresh')}
|
||||||
</div>
|
>
|
||||||
<div>
|
<RefreshCwIcon size={16} />
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.slot')}</p>
|
</Button>
|
||||||
<p>{usage.slotType}{usage.slotKey ? ` (${usage.slotKey})` : ''}</p>
|
{/snippet}
|
||||||
</div>
|
</Tooltip.Trigger>
|
||||||
<div>
|
<Tooltip.Content side="top" sideOffset={4}>
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('media_manager.usage.table.position')}</p>
|
{$t('media_manager.usage.refresh')}
|
||||||
<p>{usage.position ?? t.get('media_manager.common.not_available')}</p>
|
</Tooltip.Content>
|
||||||
</div>
|
</Tooltip.Root>
|
||||||
<div>
|
</Tooltip.Provider>
|
||||||
<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>
|
</div>
|
||||||
|
<AssetUsageList usages={usageData.usages} onNavigate={() => handleOpenChange(false)} />
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</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 AssetCard } from './asset-card.svelte';
|
||||||
export { default as AssetUsageDialog } from './asset-usage-dialog.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 EditAssetDialog } from './edit-asset-dialog.svelte';
|
||||||
export { default as ManageThumbnailsDialog } from './manage-thumbnails-dialog.svelte';
|
export { default as ManageThumbnailsDialog } from './manage-thumbnails-dialog.svelte';
|
||||||
export { default as MediaFilters } from './media-filters.svelte';
|
export { default as MediaFilters } from './media-filters.svelte';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
AssetCard,
|
AssetCard,
|
||||||
AssetUsageDialog,
|
AssetUsageDialog,
|
||||||
|
DeleteAssetDialog,
|
||||||
EditAssetDialog,
|
EditAssetDialog,
|
||||||
ManageThumbnailsDialog,
|
ManageThumbnailsDialog,
|
||||||
MediaFilters,
|
MediaFilters,
|
||||||
@@ -36,8 +37,10 @@
|
|||||||
let editOpen = $state(false);
|
let editOpen = $state(false);
|
||||||
let usageOpen = $state(false);
|
let usageOpen = $state(false);
|
||||||
let manageThumbsOpen = $state(false);
|
let manageThumbsOpen = $state(false);
|
||||||
|
let deleteOpen = $state(false);
|
||||||
let isSavingAsset = $state(false);
|
let isSavingAsset = $state(false);
|
||||||
let isUsageLoading = $state(false);
|
let isUsageLoading = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
let downloadingAssetId = $state<string | null>(null);
|
let downloadingAssetId = $state<string | null>(null);
|
||||||
let selectedAsset = $state<OrganizationAsset | null>(null);
|
let selectedAsset = $state<OrganizationAsset | null>(null);
|
||||||
let usageData = $state<AssetUsageGraph | null>(null);
|
let usageData = $state<AssetUsageGraph | null>(null);
|
||||||
@@ -96,11 +99,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openUsage(asset: OrganizationAsset) {
|
async function loadUsage(asset: OrganizationAsset) {
|
||||||
selectedAsset = asset;
|
|
||||||
usageOpen = true;
|
|
||||||
isUsageLoading = true;
|
isUsageLoading = true;
|
||||||
usageData = null;
|
|
||||||
try {
|
try {
|
||||||
usageData = await mediaApi.getAssetUsage(asset.id);
|
usageData = await mediaApi.getAssetUsage(asset.id);
|
||||||
} finally {
|
} 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() {
|
function resetUsageModalState() {
|
||||||
selectedAsset = null;
|
selectedAsset = null;
|
||||||
usageData = null;
|
usageData = null;
|
||||||
isUsageLoading = false;
|
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) {
|
async function downloadAsset(asset: OrganizationAsset) {
|
||||||
downloadingAssetId = asset.id;
|
downloadingAssetId = asset.id;
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +209,7 @@
|
|||||||
onUsage={openUsage}
|
onUsage={openUsage}
|
||||||
onDownload={downloadAsset}
|
onDownload={downloadAsset}
|
||||||
onManageThumbnails={openManageThumbnails}
|
onManageThumbnails={openManageThumbnails}
|
||||||
|
onDelete={openDelete}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
@@ -208,5 +250,17 @@
|
|||||||
{selectedAsset}
|
{selectedAsset}
|
||||||
{usageData}
|
{usageData}
|
||||||
isLoading={isUsageLoading}
|
isLoading={isUsageLoading}
|
||||||
|
onRefresh={refreshUsage}
|
||||||
onOpenChange={handleUsageOpenChange}
|
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');
|
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 {
|
export function formatUsageDate(value: string | null | undefined): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return t.get('media_manager.common.not_available');
|
return t.get('media_manager.common.not_available');
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder",
|
"thumbnail_regen_failed": "Kunne ikke genskabe miniaturebilleder",
|
||||||
"transcript_saved": "Udskrift opdateret",
|
"transcript_saved": "Udskrift opdateret",
|
||||||
"transcript_save_failed": "Kunne ikke gemme transskription",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Taggruppe oprettet",
|
"group_created": "Taggruppe oprettet",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Motion",
|
"exercise": "Motion",
|
||||||
"question": "Spørgsmål",
|
"question": "Spørgsmål",
|
||||||
"unknown": "Ukendt"
|
"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": {
|
"form": {
|
||||||
"file_name": "Filnavn",
|
"file_name": "Filnavn",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Rediger",
|
"edit": "Rediger",
|
||||||
"where_used": "Hvor brugt",
|
"where_used": "Hvor brugt",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"manage_thumbnails": "Administrer thumbnails"
|
"manage_thumbnails": "Administrer thumbnails",
|
||||||
|
"delete": "Slet"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Forhåndsvisning af tilpasset miniaturebillede",
|
"custom_preview_alt": "Forhåndsvisning af tilpasset miniaturebillede",
|
||||||
"regenerate": "Regenerer",
|
"regenerate": "Regenerer",
|
||||||
"save_selection": "Gem thumbnail"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden",
|
"thumbnail_regen_failed": "Die Miniaturansichten konnten nicht neu generiert werden",
|
||||||
"transcript_saved": "Transkript aktualisiert",
|
"transcript_saved": "Transkript aktualisiert",
|
||||||
"transcript_save_failed": "Das Transkript konnte nicht gespeichert werden",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Tag-Gruppe erstellt",
|
"group_created": "Tag-Gruppe erstellt",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Übung",
|
"exercise": "Übung",
|
||||||
"question": "Frage",
|
"question": "Frage",
|
||||||
"unknown": "Unbekannt"
|
"unknown": "Unbekannt"
|
||||||
}
|
},
|
||||||
|
"go_to_lesson": "Gehe zur Lektion",
|
||||||
|
"slot_type": {
|
||||||
|
"lesson_video": "Unterrichtsvideo",
|
||||||
|
"lesson_document": "Unterrichtsdokument",
|
||||||
|
"unknown": "Anhang"
|
||||||
|
},
|
||||||
|
"refresh": "Nutzung aktualisieren"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"file_name": "Dateiname",
|
"file_name": "Dateiname",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"where_used": "Wo verwendet",
|
"where_used": "Wo verwendet",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"manage_thumbnails": "Miniaturansichten verwalten"
|
"manage_thumbnails": "Miniaturansichten verwalten",
|
||||||
|
"delete": "Löschen"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Benutzerdefinierte Miniaturvorschau",
|
"custom_preview_alt": "Benutzerdefinierte Miniaturvorschau",
|
||||||
"regenerate": "Regenerieren",
|
"regenerate": "Regenerieren",
|
||||||
"save_selection": "Miniaturansicht speichern"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -1081,7 +1081,18 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"where_used": "Where used",
|
"where_used": "Where used",
|
||||||
"download": "Download",
|
"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": {
|
"pagination": {
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
@@ -1128,12 +1139,19 @@
|
|||||||
"loading": "Loading usage details...",
|
"loading": "Loading usage details...",
|
||||||
"empty": "This asset is not attached anywhere yet.",
|
"empty": "This asset is not attached anywhere yet.",
|
||||||
"total": "Total usages:",
|
"total": "Total usages:",
|
||||||
|
"go_to_lesson": "Go to lesson",
|
||||||
|
"refresh": "Refresh usage",
|
||||||
"target": {
|
"target": {
|
||||||
"lesson": "Lesson",
|
"lesson": "Lesson",
|
||||||
"exercise": "Exercise",
|
"exercise": "Exercise",
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
|
"slot_type": {
|
||||||
|
"lesson_video": "Lesson video",
|
||||||
|
"lesson_document": "Lesson document",
|
||||||
|
"unknown": "Attachment"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"target_type": "Target Type",
|
"target_type": "Target Type",
|
||||||
"target_id": "Target ID",
|
"target_id": "Target ID",
|
||||||
@@ -4121,6 +4139,9 @@
|
|||||||
"update_failed": "Failed to update media asset",
|
"update_failed": "Failed to update media asset",
|
||||||
"update_success": "Media asset updated",
|
"update_success": "Media asset updated",
|
||||||
"usage_failed": "Failed to load asset usage",
|
"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",
|
"download_failed": "Failed to get asset download URL",
|
||||||
"thumbnail_uploaded": "Thumbnail uploaded",
|
"thumbnail_uploaded": "Thumbnail uploaded",
|
||||||
"thumbnail_upload_failed": "Failed to upload thumbnail",
|
"thumbnail_upload_failed": "Failed to upload thumbnail",
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "No se pudieron regenerar las miniaturas",
|
"thumbnail_regen_failed": "No se pudieron regenerar las miniaturas",
|
||||||
"transcript_saved": "Transcripción actualizada",
|
"transcript_saved": "Transcripción actualizada",
|
||||||
"transcript_save_failed": "No se pudo guardar la transcripción",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Grupo de etiquetas creado",
|
"group_created": "Grupo de etiquetas creado",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Ejercicio",
|
"exercise": "Ejercicio",
|
||||||
"question": "Pregunta",
|
"question": "Pregunta",
|
||||||
"unknown": "Desconocido"
|
"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": {
|
"form": {
|
||||||
"file_name": "Nombre de archivo",
|
"file_name": "Nombre de archivo",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"where_used": "donde se usa",
|
"where_used": "donde se usa",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"manage_thumbnails": "Administrar miniaturas"
|
"manage_thumbnails": "Administrar miniaturas",
|
||||||
|
"delete": "Eliminar"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Vista previa en miniatura personalizada",
|
"custom_preview_alt": "Vista previa en miniatura personalizada",
|
||||||
"regenerate": "regenerar",
|
"regenerate": "regenerar",
|
||||||
"save_selection": "Guardar miniatura"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Échec de la régénération des miniatures",
|
"thumbnail_regen_failed": "Échec de la régénération des miniatures",
|
||||||
"transcript_saved": "Transcription mise à jour",
|
"transcript_saved": "Transcription mise à jour",
|
||||||
"transcript_save_failed": "Impossible d'enregistrer la transcription",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Groupe de balises créé",
|
"group_created": "Groupe de balises créé",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Exercice",
|
"exercise": "Exercice",
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"unknown": "Inconnu"
|
"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": {
|
"form": {
|
||||||
"file_name": "Nom du fichier",
|
"file_name": "Nom du fichier",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"where_used": "Où utilisé",
|
"where_used": "Où utilisé",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"manage_thumbnails": "Gérer les vignettes"
|
"manage_thumbnails": "Gérer les vignettes",
|
||||||
|
"delete": "Supprimer"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Aperçu miniature personnalisé",
|
"custom_preview_alt": "Aperçu miniature personnalisé",
|
||||||
"regenerate": "Régénérer",
|
"regenerate": "Régénérer",
|
||||||
"save_selection": "Enregistrer la vignette"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल",
|
"thumbnail_regen_failed": "थंबनेल पुन: उत्पन्न करने में विफल",
|
||||||
"transcript_saved": "प्रतिलेख अद्यतन किया गया",
|
"transcript_saved": "प्रतिलेख अद्यतन किया गया",
|
||||||
"transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका",
|
"transcript_save_failed": "प्रतिलेख सहेजा नहीं जा सका",
|
||||||
"transcription_already_running": "इस वीडियो के लिए प्रतिलेखन कार्य पहले से ही प्रगति पर है"
|
"transcription_already_running": "इस वीडियो के लिए प्रतिलेखन कार्य पहले से ही प्रगति पर है",
|
||||||
|
"video_added_to_lesson": "पाठ में वीडियो जोड़ा गया",
|
||||||
|
"delete_success": "वीडियो हटा दिया गया",
|
||||||
|
"delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.",
|
||||||
|
"delete_failed": "वीडियो हटाने में विफल"
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "टैग समूह बनाया गया",
|
"group_created": "टैग समूह बनाया गया",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "व्यायाम",
|
"exercise": "व्यायाम",
|
||||||
"question": "प्रश्न",
|
"question": "प्रश्न",
|
||||||
"unknown": "अज्ञात"
|
"unknown": "अज्ञात"
|
||||||
}
|
},
|
||||||
|
"go_to_lesson": "पाठ पर जाएँ",
|
||||||
|
"slot_type": {
|
||||||
|
"lesson_video": "पाठ वीडियो",
|
||||||
|
"lesson_document": "पाठ दस्तावेज़",
|
||||||
|
"unknown": "लगाव"
|
||||||
|
},
|
||||||
|
"refresh": "उपयोग ताज़ा करें"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"file_name": "फ़ाइल का नाम",
|
"file_name": "फ़ाइल का नाम",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "संपादित करें",
|
"edit": "संपादित करें",
|
||||||
"where_used": "कहां उपयोग किया जाता है",
|
"where_used": "कहां उपयोग किया जाता है",
|
||||||
"download": "डाउनलोड करें",
|
"download": "डाउनलोड करें",
|
||||||
"manage_thumbnails": "थंबनेल प्रबंधित करें"
|
"manage_thumbnails": "थंबनेल प्रबंधित करें",
|
||||||
|
"delete": "हटाएँ"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "शीर्षक",
|
"title": "शीर्षक",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "कस्टम थंबनेल पूर्वावलोकन",
|
"custom_preview_alt": "कस्टम थंबनेल पूर्वावलोकन",
|
||||||
"regenerate": "पुनर्जीवित करें",
|
"regenerate": "पुनर्जीवित करें",
|
||||||
"save_selection": "थंबनेल सहेजें"
|
"save_selection": "थंबनेल सहेजें"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "वीडियो हटाएँ",
|
||||||
|
"irreversible_title": "इसे पूर्ववत नहीं किया जा सकता",
|
||||||
|
"irreversible_description": "यह वीडियो और इसकी सभी फ़ाइलें - प्रतिपादन, थंबनेल और कैप्शन - स्थायी रूप से हटा दिए जाएंगे और पुनर्प्राप्त नहीं किए जा सकेंगे।",
|
||||||
|
"in_use_title": "अभी भी उपयोग में है",
|
||||||
|
"in_use_description": "यह वीडियो नीचे दिए गए पाठों से जुड़ा हुआ है। प्रत्येक पाठ खोलें, वहां से वीडियो हटाएं, फिर उसे हटाने के लिए वापस आएं।",
|
||||||
|
"confirm": "स्थायी रूप से हटाएँ",
|
||||||
|
"cancel": "रद्द करें",
|
||||||
|
"close": "बंद करें"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur",
|
"thumbnail_regen_failed": "Nie udało się ponownie wygenerować miniatur",
|
||||||
"transcript_saved": "Transkrypcja zaktualizowana",
|
"transcript_saved": "Transkrypcja zaktualizowana",
|
||||||
"transcript_save_failed": "Nie udało się zapisać transkrypcji",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Utworzono grupę tagów",
|
"group_created": "Utworzono grupę tagów",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Ćwicz",
|
"exercise": "Ćwicz",
|
||||||
"question": "Pytanie",
|
"question": "Pytanie",
|
||||||
"unknown": "Nieznany"
|
"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": {
|
"form": {
|
||||||
"file_name": "Nazwa pliku",
|
"file_name": "Nazwa pliku",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
"where_used": "Gdzie używany",
|
"where_used": "Gdzie używany",
|
||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"manage_thumbnails": "Zarządzaj miniaturami"
|
"manage_thumbnails": "Zarządzaj miniaturami",
|
||||||
|
"delete": "Usuń"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tytuł",
|
"title": "Tytuł",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Niestandardowy podgląd miniatur",
|
"custom_preview_alt": "Niestandardowy podgląd miniatur",
|
||||||
"regenerate": "Zregeneruj się",
|
"regenerate": "Zregeneruj się",
|
||||||
"save_selection": "Zapisz miniaturę"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Falha ao regenerar miniaturas",
|
"thumbnail_regen_failed": "Falha ao regenerar miniaturas",
|
||||||
"transcript_saved": "Transcrição atualizada",
|
"transcript_saved": "Transcrição atualizada",
|
||||||
"transcript_save_failed": "Não foi possível salvar a transcrição",
|
"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": {
|
"tags": {
|
||||||
"group_created": "Grupo de tags criado",
|
"group_created": "Grupo de tags criado",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Exercício",
|
"exercise": "Exercício",
|
||||||
"question": "Pergunta",
|
"question": "Pergunta",
|
||||||
"unknown": "Desconhecido"
|
"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": {
|
"form": {
|
||||||
"file_name": "Nome do arquivo",
|
"file_name": "Nome do arquivo",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"where_used": "Onde usado",
|
"where_used": "Onde usado",
|
||||||
"download": "Baixar",
|
"download": "Baixar",
|
||||||
"manage_thumbnails": "Gerenciar miniaturas"
|
"manage_thumbnails": "Gerenciar miniaturas",
|
||||||
|
"delete": "Excluir"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Pré-visualização de miniaturas personalizadas",
|
"custom_preview_alt": "Pré-visualização de miniaturas personalizadas",
|
||||||
"regenerate": "Regenerar",
|
"regenerate": "Regenerar",
|
||||||
"save_selection": "Salvar miniatura"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Не удалось восстановить миниатюры.",
|
"thumbnail_regen_failed": "Не удалось восстановить миниатюры.",
|
||||||
"transcript_saved": "Стенограмма обновлена",
|
"transcript_saved": "Стенограмма обновлена",
|
||||||
"transcript_save_failed": "Не удалось сохранить расшифровку",
|
"transcript_save_failed": "Не удалось сохранить расшифровку",
|
||||||
"transcription_already_running": "Транскрипция этого видео уже выполняется."
|
"transcription_already_running": "Транскрипция этого видео уже выполняется.",
|
||||||
|
"video_added_to_lesson": "Видео добавлено в урок",
|
||||||
|
"delete_success": "Видео удалено",
|
||||||
|
"delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.",
|
||||||
|
"delete_failed": "Не удалось удалить видео."
|
||||||
},
|
},
|
||||||
"tags": {
|
"tags": {
|
||||||
"group_created": "Группа тегов создана.",
|
"group_created": "Группа тегов создана.",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "Упражнение",
|
"exercise": "Упражнение",
|
||||||
"question": "Вопрос",
|
"question": "Вопрос",
|
||||||
"unknown": "Неизвестно"
|
"unknown": "Неизвестно"
|
||||||
}
|
},
|
||||||
|
"go_to_lesson": "Перейти на урок",
|
||||||
|
"slot_type": {
|
||||||
|
"lesson_video": "Видео урока",
|
||||||
|
"lesson_document": "Документ урока",
|
||||||
|
"unknown": "Приложение"
|
||||||
|
},
|
||||||
|
"refresh": "Обновить использование"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"file_name": "Имя файла",
|
"file_name": "Имя файла",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"where_used": "Где используется",
|
"where_used": "Где используется",
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"manage_thumbnails": "Управление миниатюрами"
|
"manage_thumbnails": "Управление миниатюрами",
|
||||||
|
"delete": "Удалить"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Название",
|
"title": "Название",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Пользовательский предварительный просмотр миниатюр",
|
"custom_preview_alt": "Пользовательский предварительный просмотр миниатюр",
|
||||||
"regenerate": "Регенерировать",
|
"regenerate": "Регенерировать",
|
||||||
"save_selection": "Сохранить миниатюру"
|
"save_selection": "Сохранить миниатюру"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Удалить видео",
|
||||||
|
"irreversible_title": "Это нельзя отменить",
|
||||||
|
"irreversible_description": "Это видео и все его файлы — версии, миниатюры и подписи — будут удалены без возможности восстановления без возможности восстановления.",
|
||||||
|
"in_use_title": "Все еще используется",
|
||||||
|
"in_use_description": "Это видео прикреплено к урокам ниже. Откройте каждый урок, удалите оттуда видео, затем вернитесь, чтобы удалить его.",
|
||||||
|
"confirm": "Удалить навсегда",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"close": "Закрыть"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -357,7 +357,11 @@
|
|||||||
"thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ",
|
"thumbnail_regen_failed": "Không thể tạo lại hình thu nhỏ",
|
||||||
"transcript_saved": "Đã cập nhật bản ghi",
|
"transcript_saved": "Đã cập nhật bản ghi",
|
||||||
"transcript_save_failed": "Không thể lưu 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": {
|
"tags": {
|
||||||
"group_created": "Đã tạo nhóm thẻ",
|
"group_created": "Đã tạo nhóm thẻ",
|
||||||
@@ -2808,7 +2812,14 @@
|
|||||||
"exercise": "tập thể dục",
|
"exercise": "tập thể dục",
|
||||||
"question": "Câu hỏi",
|
"question": "Câu hỏi",
|
||||||
"unknown": "Không xác định"
|
"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": {
|
"form": {
|
||||||
"file_name": "Tên tệp",
|
"file_name": "Tên tệp",
|
||||||
@@ -2838,7 +2849,8 @@
|
|||||||
"edit": "Chỉnh sửa",
|
"edit": "Chỉnh sửa",
|
||||||
"where_used": "Nơi sử dụng",
|
"where_used": "Nơi sử dụng",
|
||||||
"download": "Tải xuố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": {
|
"table": {
|
||||||
"title": "Tiêu đề",
|
"title": "Tiêu đề",
|
||||||
@@ -2910,6 +2922,16 @@
|
|||||||
"custom_preview_alt": "Xem trước hình thu nhỏ tùy chỉnh",
|
"custom_preview_alt": "Xem trước hình thu nhỏ tùy chỉnh",
|
||||||
"regenerate": "tái sinh",
|
"regenerate": "tái sinh",
|
||||||
"save_selection": "Lưu hình thu nhỏ"
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import './../bootstrap';
|
|||||||
import { Queue, Worker } from 'bullmq';
|
import { Queue, Worker } from 'bullmq';
|
||||||
|
|
||||||
import { runAnalyticsRollupDaily } from '@cio/analytics';
|
import { runAnalyticsRollupDaily } from '@cio/analytics';
|
||||||
|
import { purgeAssetStorage } from '@cio/core/services/assets/assets';
|
||||||
import { pruneDeadLetterJobsOlderThan, reapStuckMediaJobs } from '@cio/db/queries';
|
import { pruneDeadLetterJobsOlderThan, reapStuckMediaJobs } from '@cio/db/queries';
|
||||||
import {
|
import {
|
||||||
JOB_NAMES,
|
JOB_NAMES,
|
||||||
QUEUE_NAMES,
|
QUEUE_NAMES,
|
||||||
ZAnalyticsDailyRollupPayload,
|
ZAnalyticsDailyRollupPayload,
|
||||||
|
ZAssetStorageCleanupPayload,
|
||||||
ZDeadLetterCleanupPayload,
|
ZDeadLetterCleanupPayload,
|
||||||
ZMediaJobReapPayload,
|
ZMediaJobReapPayload,
|
||||||
ZRetentionCompactPayload,
|
ZRetentionCompactPayload,
|
||||||
@@ -55,6 +57,17 @@ const worker = new Worker(
|
|||||||
return { reaped: reaped.length };
|
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) {
|
if (job.name === JOB_NAMES.maintenance.analyticsDailyRollup) {
|
||||||
const data = ZAnalyticsDailyRollupPayload.parse(job.data ?? {});
|
const data = ZAnalyticsDailyRollupPayload.parse(job.data ?? {});
|
||||||
const result = await runAnalyticsRollupDaily({ daysAgo: data.daysAgo });
|
const result = await runAnalyticsRollupDaily({ daysAgo: data.daysAgo });
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type {
|
|||||||
} from '@cio/utils/validation/assets';
|
} from '@cio/utils/validation/assets';
|
||||||
import type { TTranscriptResponse, TUpdateTranscript } from '@cio/utils/validation/media';
|
import type { TTranscriptResponse, TUpdateTranscript } from '@cio/utils/validation/media';
|
||||||
import {
|
import {
|
||||||
countAssetUsagesByAsset,
|
|
||||||
createAssetUsage,
|
createAssetUsage,
|
||||||
createHlsAssetPlaceholder,
|
createHlsAssetPlaceholder,
|
||||||
createOrGetAssetByStorageKey,
|
createOrGetAssetByStorageKey,
|
||||||
@@ -32,14 +31,17 @@ import {
|
|||||||
listAssetUsagesByAsset,
|
listAssetUsagesByAsset,
|
||||||
updateAsset
|
updateAsset
|
||||||
} from '@cio/db/queries/assets';
|
} from '@cio/db/queries/assets';
|
||||||
import { getMediaTranscriptByAsset, updateMediaTranscriptContent } from '@cio/db/queries';
|
import { getLessonNavInfoByIds, getMediaTranscriptByAsset, updateMediaTranscriptContent } from '@cio/db/queries';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteFromS3,
|
||||||
generateTranscriptVttPresignedUrl,
|
generateTranscriptVttPresignedUrl,
|
||||||
generateUploadPresignedUrl,
|
generateUploadPresignedUrl,
|
||||||
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
TRANSCRIPT_VTT_PRESIGN_SECONDS
|
||||||
} from '../../utils/s3';
|
} from '../../utils/s3';
|
||||||
import { getS3Client, getStorageConfig } from '../../config/storage';
|
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}$/;
|
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) {
|
export async function deleteAssetService(orgId: string, assetId: string) {
|
||||||
try {
|
try {
|
||||||
await getAssetService(orgId, assetId);
|
const asset = await getAssetService(orgId, assetId);
|
||||||
|
|
||||||
const usageCount = await countAssetUsagesByAsset(assetId, orgId);
|
const liveUsages = await resolveLiveAssetUsages(orgId, assetId);
|
||||||
if (usageCount > 0) {
|
if (liveUsages.length > 0) {
|
||||||
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
|
throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanupPayload = buildAssetStorageCleanupPayload(asset);
|
||||||
|
|
||||||
const deleted = await deleteAsset(assetId, orgId);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -390,13 +481,10 @@ export async function deleteAssetService(orgId: string, assetId: string) {
|
|||||||
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
|
export async function getAssetUsageGraphService(orgId: string, assetId: string) {
|
||||||
try {
|
try {
|
||||||
await getAssetService(orgId, assetId);
|
await getAssetService(orgId, assetId);
|
||||||
const [usages, usageCount] = await Promise.all([
|
const usages = await resolveLiveAssetUsages(orgId, assetId);
|
||||||
listAssetUsagesByAsset(assetId, orgId),
|
|
||||||
countAssetUsagesByAsset(assetId, orgId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
usageCount,
|
usageCount: usages.length,
|
||||||
usages
|
usages
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { deleteCourseSection, getCourseSectionById, getCourseSectionsByCourseId
|
|||||||
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
import { OperationalQueryError } from '@cio/db/queries/query-errors';
|
||||||
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||||
import { deleteLesson } from '@cio/db/queries/lesson';
|
import { deleteLesson } from '@cio/db/queries/lesson';
|
||||||
|
import { deleteAssetUsagesByTarget } from '@cio/db/queries/assets';
|
||||||
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
import { deleteExercise } from '@cio/db/queries/exercise/exercise';
|
||||||
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
import type { TCourseContentDelete, TCourseContentReorder, TCourseContentUpdate } from '@cio/utils/validation/course';
|
||||||
|
|
||||||
@@ -198,6 +199,8 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
|||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deleteAssetUsagesByTarget('lesson', item.id, tx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +235,8 @@ export async function deleteCourseContent(courseId: string, payload: TCourseCont
|
|||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
throw new AppError('Lesson not found', ErrorCodes.LESSON_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deleteAssetUsagesByTarget('lesson', item.id, tx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from '@cio/db/queries/lesson';
|
} from '@cio/db/queries/lesson';
|
||||||
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
import type { TUpdateLessonWatchProgress } from '@cio/utils/validation/lesson';
|
||||||
import { touchCourseUpdatedAt } from '@cio/db/queries/course';
|
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 { enrichLessonWithPresignedUrls } from '../../utils/lesson-media';
|
||||||
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
|
import { resolveWatchEnforcedAssetIds } from '../../utils/lesson-watch-enforcement';
|
||||||
import { resolveItemSlug } from '../course/slug';
|
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> {
|
export async function deleteLessonService(lessonId: string): Promise<TLesson> {
|
||||||
try {
|
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) {
|
if (!deleted) {
|
||||||
throw new AppError('Failed to delete lesson', ErrorCodes.INTERNAL_ERROR, 500);
|
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",
|
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
||||||
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
||||||
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.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:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||||
"move-users": "tsx src/scripts/supabase-to-betterauth.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[]> {
|
export async function listAssetUsagesByAsset(assetId: string, orgId: string): Promise<TAssetUsage[]> {
|
||||||
try {
|
try {
|
||||||
return await db
|
return await db
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
TNewLessonCompletion,
|
TNewLessonCompletion,
|
||||||
TNewLessonLanguage
|
TNewLessonLanguage
|
||||||
} from '@db/types';
|
} 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';
|
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[]) {
|
export async function createLessons(values: TNewLesson[]) {
|
||||||
try {
|
try {
|
||||||
return db.insert(schema.lesson).values(values).returning();
|
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 './emails';
|
||||||
export * from './agent-course-generation';
|
export * from './agent-course-generation';
|
||||||
export * from './notifications';
|
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)
|
staleAfterMinutes: z.number().int().positive().default(30)
|
||||||
});
|
});
|
||||||
export type TMediaJobReapPayload = z.infer<typeof ZMediaJobReapPayload>;
|
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',
|
retentionCompact: 'retention-compact',
|
||||||
deadLetterCleanup: 'dead-letter-cleanup',
|
deadLetterCleanup: 'dead-letter-cleanup',
|
||||||
mediaJobReap: 'media-job-reap',
|
mediaJobReap: 'media-job-reap',
|
||||||
analyticsDailyRollup: 'analytics-daily-rollup'
|
analyticsDailyRollup: 'analytics-daily-rollup',
|
||||||
|
assetStorageCleanup: 'asset-storage-cleanup'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user