Files
classroomio/packages/db/src/queries/assets/assets.ts
T
Rotimi Best f18e5600f6 fix(media): prevent attaching an asset to a target twice
Drop `position` from the `asset_usages` unique constraint so the same
asset can't be attached to one target/slot more than once, and update
the insert's onConflict target + lookup to match the narrower key.

Guard it at the service layer too: attachAssetService now rejects an
attach when the asset already has a usage for that target with a new
ASSET_ALREADY_ATTACHED (409) error, backed by an
assetUsageExistsForTarget query.
2026-06-28 23:05:57 +01:00

664 lines
20 KiB
TypeScript

import * as schema from '@db/schema';
import { and, asc, count, desc, eq, inArray, ne, sql, type SQL, type InferSelectModel } from '@db/drizzle';
import { db, type DbOrTxClient } from '@db/drizzle';
import type { TAsset, TAssetUsage, TNewAsset, TNewAssetUsage } from '@db/types';
export interface ListAssetsOptions {
page?: number;
limit?: number;
kind?: string;
status?: string;
search?: string;
includeExternal?: boolean;
}
export interface ListAssetsResult {
items: TAsset[];
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface AssetStorageSummary {
totalBytes: number;
internalBytes: number;
externalAssetCount: number;
bytesByKind: Record<string, number>;
}
export interface AssetDetachInput {
usageId?: string;
targetType?: string;
targetId?: string;
slotType?: string;
slotKey?: string | null;
position?: number | null;
}
function buildAssetWhereConditions(orgId: string, options: ListAssetsOptions): SQL[] {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (options.kind) {
conditions.push(eq(schema.asset.kind, options.kind));
}
if (options.status) {
conditions.push(eq(schema.asset.status, options.status));
}
if (options.includeExternal === false) {
conditions.push(eq(schema.asset.isExternal, false));
}
if (options.search?.trim()) {
const search = `%${options.search.trim().toLowerCase()}%`;
conditions.push(
sql`(
lower(coalesce(${schema.asset.title}, '')) like ${search}
or lower(coalesce(${schema.asset.description}, '')) like ${search}
or lower(coalesce(${schema.asset.storageKey}, '')) like ${search}
)`
);
}
return conditions;
}
export async function createAsset(values: TNewAsset, dbClient: DbOrTxClient = db): Promise<TAsset> {
try {
const [created] = await dbClient.insert(schema.asset).values(values).returning();
if (!created) {
throw new Error('Failed to create asset');
}
return created;
} catch (error) {
console.error('createAsset error:', error);
throw new Error(`Failed to create asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createOrGetAssetByStorageKey(values: TNewAsset, dbClient: DbOrTxClient = db): Promise<TAsset> {
try {
if (!values.storageKey) {
return createAsset(values, dbClient);
}
const [createdOrUpdated] = await dbClient
.insert(schema.asset)
.values(values)
.onConflictDoUpdate({
target: [schema.asset.organizationId, schema.asset.provider, schema.asset.storageKey],
set: {
sourceUrl: values.sourceUrl,
mimeType: values.mimeType,
byteSize: values.byteSize,
checksum: values.checksum,
title: values.title,
description: values.description,
thumbnailUrl: values.thumbnailUrl,
durationSeconds: values.durationSeconds,
aspectRatio: values.aspectRatio,
metadata: values.metadata,
updatedAt: new Date().toISOString()
}
})
.returning();
if (!createdOrUpdated) {
throw new Error('Failed to create or get asset');
}
return createdOrUpdated;
} catch (error) {
console.error('createOrGetAssetByStorageKey error:', error);
throw new Error(`Failed to create or get asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetById(assetId: string, orgId?: string): Promise<TAsset | null> {
try {
const conditions = [eq(schema.asset.id, assetId)];
if (orgId) {
conditions.push(eq(schema.asset.organizationId, orgId));
}
const [item] = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.limit(1);
return item || null;
} catch (error) {
console.error('getAssetById error:', error);
throw new Error(`Failed to get asset by id: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetsByIds(assetIds: string[], orgId?: string): Promise<TAsset[]> {
try {
if (assetIds.length === 0) {
return [];
}
const conditions: SQL[] = [inArray(schema.asset.id, assetIds)];
if (orgId) {
conditions.push(eq(schema.asset.organizationId, orgId));
}
return await db
.select()
.from(schema.asset)
.where(and(...conditions));
} catch (error) {
console.error('getAssetsByIds error:', error);
throw new Error(`Failed to get assets by ids: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listAssetsByOrg(orgId: string, options: ListAssetsOptions = {}): Promise<ListAssetsResult> {
try {
const page = options.page && options.page > 0 ? options.page : 1;
const limit = options.limit && options.limit > 0 ? Math.min(options.limit, 100) : 20;
const offset = (page - 1) * limit;
const conditions = buildAssetWhereConditions(orgId, options);
const [totalRow] = await db
.select({ count: count(schema.asset.id) })
.from(schema.asset)
.where(and(...conditions));
const total = Number(totalRow?.count ?? 0);
const items = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.orderBy(desc(schema.asset.createdAt))
.limit(limit)
.offset(offset);
return {
items,
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit))
};
} catch (error) {
console.error('listAssetsByOrg error:', error);
throw new Error(
`Failed to list assets by organization: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function updateAsset(
assetId: string,
orgId: string,
data: Partial<TAsset>,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('updateAsset error:', error);
throw new Error(`Failed to update asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export interface InitHlsAssetInput {
organizationId: string;
createdByProfileId: string;
title: string | null;
byteSize: number | null;
mimeType: string | null;
}
/**
* Insert an asset row in `processing` status for an HLS upload. The
* storage layout (storageKey / hlsManifestKey / hlsAudioKey) is filled in
* by finalizeHlsAsset once the browser encoder has written everything to
* R2 — we just need an id to namespace the segments under.
*/
export async function createHlsAssetPlaceholder(
input: InitHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset> {
try {
const [created] = await dbClient
.insert(schema.asset)
.values({
organizationId: input.organizationId,
kind: 'video',
provider: 'upload',
storageProvider: 's3',
title: input.title,
byteSize: input.byteSize,
mimeType: input.mimeType,
isExternal: false,
status: 'processing',
createdByProfileId: input.createdByProfileId
})
.returning();
if (!created) {
throw new Error('Failed to create HLS asset placeholder');
}
return created;
} catch (error) {
console.error('createHlsAssetPlaceholder error:', error);
throw new Error(
`Failed to create HLS asset placeholder: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface FinalizeHlsAssetInput {
manifestKey: string;
audioKey: string | null;
durationSeconds: number | null;
aspectRatio: string | null;
byteSize: number;
thumbnailUrl: string | null;
thumbnailCandidates: string[];
metadata: Record<string, unknown>;
}
export async function finalizeHlsAsset(
assetId: string,
orgId: string,
input: FinalizeHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
hlsManifestKey: input.manifestKey,
hlsAudioKey: input.audioKey,
// storageKey stays null for HLS assets so lesson-media.ts (which
// signs storageKey as the canonical video URL) doesn't overwrite
// the HLS manifest URL. The transcription enqueue path reads
// hlsAudioKey for HLS assets — see startTranscriptionOnlyMediaJob.
durationSeconds: input.durationSeconds,
aspectRatio: input.aspectRatio,
byteSize: input.byteSize,
thumbnailUrl: input.thumbnailUrl,
thumbnailCandidates: input.thumbnailCandidates,
metadata: input.metadata,
status: 'active',
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHlsAsset error:', error);
throw new Error(`Failed to finalize HLS asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export interface FinalizeHls1080Input {
byteSize: number;
metadata: Record<string, unknown>;
}
export async function finalizeHls1080Rendition(
assetId: string,
orgId: string,
input: FinalizeHls1080Input,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
byteSize: input.byteSize,
metadata: input.metadata,
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHls1080Rendition error:', error);
throw new Error(
`Failed to finalize HLS 1080p rendition: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function archiveAsset(
assetId: string,
orgId: string,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({ status: 'archived', updatedAt: new Date().toISOString() })
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('archiveAsset error:', error);
throw new Error(`Failed to archive asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function deleteAsset(assetId: string, orgId: string, dbClient: DbOrTxClient = db): Promise<TAsset | null> {
try {
const [deleted] = await dbClient
.delete(schema.asset)
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteAsset error:', error);
throw new Error(`Failed to delete asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxClient = db): Promise<TAssetUsage> {
try {
const slotType = values.slotType ?? 'lesson_video';
const [created] = await dbClient
.insert(schema.assetUsage)
.values({ ...values, slotType })
.onConflictDoNothing({
target: [
schema.assetUsage.assetId,
schema.assetUsage.targetType,
schema.assetUsage.targetId,
schema.assetUsage.slotType,
schema.assetUsage.slotKey
]
})
.returning();
if (created) {
return created;
}
const [existing] = await dbClient
.select()
.from(schema.assetUsage)
.where(
and(
eq(schema.assetUsage.assetId, values.assetId),
eq(schema.assetUsage.targetType, values.targetType),
eq(schema.assetUsage.targetId, values.targetId),
eq(schema.assetUsage.slotType, slotType),
values.slotKey == null
? sql`${schema.assetUsage.slotKey} is null`
: eq(schema.assetUsage.slotKey, values.slotKey)
)
)
.limit(1);
if (!existing) {
throw new Error('Failed to create or get asset usage');
}
return existing;
} catch (error) {
console.error('createAssetUsage error:', error);
throw new Error(`Failed to create asset usage: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function deleteAssetUsage(
orgId: string,
assetId: string,
input: AssetDetachInput,
dbClient: DbOrTxClient = db
): Promise<TAssetUsage | null> {
try {
const conditions: SQL[] = [eq(schema.assetUsage.organizationId, orgId), eq(schema.assetUsage.assetId, assetId)];
if (input.usageId) {
conditions.push(eq(schema.assetUsage.id, input.usageId));
} else {
if (!input.targetType || !input.targetId || !input.slotType) {
throw new Error('usageId or targetType/targetId/slotType is required');
}
conditions.push(eq(schema.assetUsage.targetType, input.targetType));
conditions.push(eq(schema.assetUsage.targetId, input.targetId));
conditions.push(eq(schema.assetUsage.slotType, input.slotType));
if (input.slotKey == null) {
conditions.push(sql`${schema.assetUsage.slotKey} is null`);
} else {
conditions.push(eq(schema.assetUsage.slotKey, input.slotKey));
}
if (input.position == null) {
conditions.push(sql`${schema.assetUsage.position} is null`);
} else {
conditions.push(eq(schema.assetUsage.position, input.position));
}
}
const [deleted] = await dbClient
.delete(schema.assetUsage)
.where(and(...conditions))
.returning();
return deleted || null;
} catch (error) {
console.error('deleteAssetUsage error:', error);
throw new Error(`Failed to delete asset usage: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* 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
.select()
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.assetId, assetId), eq(schema.assetUsage.organizationId, orgId)))
.orderBy(asc(schema.assetUsage.targetType), asc(schema.assetUsage.slotType), asc(schema.assetUsage.createdAt));
} catch (error) {
console.error('listAssetUsagesByAsset error:', error);
throw new Error(`Failed to list asset usages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function assetUsageExistsForTarget(
assetId: string,
orgId: string,
targetType: string,
targetId: string
): Promise<boolean> {
try {
const [existing] = await db
.select({ id: schema.assetUsage.id })
.from(schema.assetUsage)
.where(
and(
eq(schema.assetUsage.assetId, assetId),
eq(schema.assetUsage.organizationId, orgId),
eq(schema.assetUsage.targetType, targetType),
eq(schema.assetUsage.targetId, targetId)
)
)
.limit(1);
return Boolean(existing);
} catch (error) {
console.error('assetUsageExistsForTarget error:', error);
throw new Error(
`Failed to check asset usage for target: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function countAssetUsagesByAsset(assetId: string, orgId: string): Promise<number> {
try {
const [result] = await db
.select({ count: count(schema.assetUsage.id) })
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.assetId, assetId), eq(schema.assetUsage.organizationId, orgId)));
return Number(result?.count ?? 0);
} catch (error) {
console.error('countAssetUsagesByAsset error:', error);
throw new Error(`Failed to count asset usages: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function listAssetsForExport(
orgId: string,
options: { includeArchived?: boolean; includeExternal?: boolean } = {}
): Promise<Array<TAsset & { usageCount: number; usages: TAssetUsage[] }>> {
try {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (!options.includeArchived) {
conditions.push(ne(schema.asset.status, 'archived'));
}
if (!options.includeExternal) {
conditions.push(eq(schema.asset.isExternal, false));
}
const assets = await db
.select()
.from(schema.asset)
.where(and(...conditions))
.orderBy(desc(schema.asset.createdAt));
if (!assets.length) {
return [];
}
const assetIds = assets.map((item) => item.id);
const usages = await db
.select()
.from(schema.assetUsage)
.where(and(eq(schema.assetUsage.organizationId, orgId), inArray(schema.assetUsage.assetId, assetIds)));
const usageByAsset = new Map<string, TAssetUsage[]>();
for (const usage of usages) {
const existing = usageByAsset.get(usage.assetId) || [];
existing.push(usage);
usageByAsset.set(usage.assetId, existing);
}
return assets.map((item) => {
const itemUsages = usageByAsset.get(item.id) || [];
return {
...item,
usageCount: itemUsages.length,
usages: itemUsages
};
});
} catch (error) {
console.error('listAssetsForExport error:', error);
throw new Error(`Failed to list assets for export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getAssetStorageSummaryByOrg(
orgId: string,
options: { includeArchived?: boolean; includeExternal?: boolean } = {}
): Promise<AssetStorageSummary> {
try {
const conditions: SQL[] = [eq(schema.asset.organizationId, orgId)];
if (!options.includeArchived) {
conditions.push(ne(schema.asset.status, 'archived'));
}
if (!options.includeExternal) {
conditions.push(eq(schema.asset.isExternal, false));
}
const grouped = await db
.select({
kind: schema.asset.kind,
bytes: sql<number>`coalesce(sum(${schema.asset.byteSize}), 0)::bigint`.as('bytes')
})
.from(schema.asset)
.where(and(...conditions))
.groupBy(schema.asset.kind);
const [totalRow] = await db
.select({
totalBytes: sql<number>`coalesce(sum(${schema.asset.byteSize}), 0)::bigint`.as('total_bytes'),
internalBytes:
sql<number>`coalesce(sum(case when ${schema.asset.isExternal} = false then ${schema.asset.byteSize} else 0 end), 0)::bigint`.as(
'internal_bytes'
),
externalAssetCount:
sql<number>`coalesce(sum(case when ${schema.asset.isExternal} = true then 1 else 0 end), 0)::int`.as(
'external_asset_count'
)
})
.from(schema.asset)
.where(and(...conditions));
const bytesByKind: Record<string, number> = {};
for (const row of grouped) {
bytesByKind[row.kind || 'unknown'] = Number(row.bytes ?? 0);
}
return {
totalBytes: Number(totalRow?.totalBytes ?? 0),
internalBytes: Number(totalRow?.internalBytes ?? 0),
externalAssetCount: Number(totalRow?.externalAssetCount ?? 0),
bytesByKind
};
} catch (error) {
console.error('getAssetStorageSummaryByOrg error:', error);
throw new Error(`Failed to get asset storage summary: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export type AssetRow = InferSelectModel<typeof schema.asset>;
export type AssetUsageRow = InferSelectModel<typeof schema.assetUsage>;