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.
This commit is contained in:
Rotimi Best
2026-06-28 23:05:57 +01:00
parent 42231b3ee1
commit f18e5600f6
4 changed files with 40 additions and 8 deletions
@@ -17,6 +17,7 @@ import type {
} from '@cio/utils/validation/assets';
import type { TTranscriptResponse, TUpdateTranscript } from '@cio/utils/validation/media';
import {
assetUsageExistsForTarget,
createAssetUsage,
createHlsAssetPlaceholder,
createOrGetAssetByStorageKey,
@@ -503,6 +504,12 @@ export async function getAssetUsageGraphService(orgId: string, assetId: string)
export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
try {
await getAssetService(orgId, assetId);
const alreadyAttached = await assetUsageExistsForTarget(assetId, orgId, input.targetType, input.targetId);
if (alreadyAttached) {
throw new AppError('Asset is already attached to this target', ErrorCodes.ASSET_ALREADY_ATTACHED, 409);
}
return await createAssetUsage({
organizationId: orgId,
assetId,
+31 -6
View File
@@ -387,8 +387,7 @@ export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxC
schema.assetUsage.targetType,
schema.assetUsage.targetId,
schema.assetUsage.slotType,
schema.assetUsage.slotKey,
schema.assetUsage.position
schema.assetUsage.slotKey
]
})
.returning();
@@ -408,10 +407,7 @@ export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxC
eq(schema.assetUsage.slotType, slotType),
values.slotKey == null
? sql`${schema.assetUsage.slotKey} is null`
: eq(schema.assetUsage.slotKey, values.slotKey),
values.position == null
? sql`${schema.assetUsage.position} is null`
: eq(schema.assetUsage.position, values.position)
: eq(schema.assetUsage.slotKey, values.slotKey)
)
)
.limit(1);
@@ -512,6 +508,35 @@ export async function listAssetUsagesByAsset(assetId: string, orgId: string): Pr
}
}
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
+1 -2
View File
@@ -1142,8 +1142,7 @@ export const assetUsage = pgTable(
table.targetType,
table.targetId,
table.slotType,
table.slotKey,
table.position
table.slotKey
)
]
);
@@ -71,6 +71,7 @@ export const ErrorCodes = {
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
ASSET_ALREADY_ATTACHED: 'ASSET_ALREADY_ATTACHED',
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',