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.
664 lines
20 KiB
TypeScript
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>;
|