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:
@@ -17,6 +17,7 @@ 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 {
|
||||||
|
assetUsageExistsForTarget,
|
||||||
createAssetUsage,
|
createAssetUsage,
|
||||||
createHlsAssetPlaceholder,
|
createHlsAssetPlaceholder,
|
||||||
createOrGetAssetByStorageKey,
|
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) {
|
export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
|
||||||
try {
|
try {
|
||||||
await getAssetService(orgId, assetId);
|
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({
|
return await createAssetUsage({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
assetId,
|
assetId,
|
||||||
|
|||||||
@@ -387,8 +387,7 @@ export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxC
|
|||||||
schema.assetUsage.targetType,
|
schema.assetUsage.targetType,
|
||||||
schema.assetUsage.targetId,
|
schema.assetUsage.targetId,
|
||||||
schema.assetUsage.slotType,
|
schema.assetUsage.slotType,
|
||||||
schema.assetUsage.slotKey,
|
schema.assetUsage.slotKey
|
||||||
schema.assetUsage.position
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -408,10 +407,7 @@ export async function createAssetUsage(values: TNewAssetUsage, dbClient: DbOrTxC
|
|||||||
eq(schema.assetUsage.slotType, slotType),
|
eq(schema.assetUsage.slotType, slotType),
|
||||||
values.slotKey == null
|
values.slotKey == null
|
||||||
? sql`${schema.assetUsage.slotKey} is null`
|
? sql`${schema.assetUsage.slotKey} is null`
|
||||||
: eq(schema.assetUsage.slotKey, values.slotKey),
|
: eq(schema.assetUsage.slotKey, values.slotKey)
|
||||||
values.position == null
|
|
||||||
? sql`${schema.assetUsage.position} is null`
|
|
||||||
: eq(schema.assetUsage.position, values.position)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.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> {
|
export async function countAssetUsagesByAsset(assetId: string, orgId: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
|
|||||||
@@ -1142,8 +1142,7 @@ export const assetUsage = pgTable(
|
|||||||
table.targetType,
|
table.targetType,
|
||||||
table.targetId,
|
table.targetId,
|
||||||
table.slotType,
|
table.slotType,
|
||||||
table.slotKey,
|
table.slotKey
|
||||||
table.position
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const ErrorCodes = {
|
|||||||
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
|
ASSET_UPDATE_FAILED: 'ASSET_UPDATE_FAILED',
|
||||||
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
|
ASSET_DELETE_FAILED: 'ASSET_DELETE_FAILED',
|
||||||
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
|
ASSET_ATTACH_FAILED: 'ASSET_ATTACH_FAILED',
|
||||||
|
ASSET_ALREADY_ATTACHED: 'ASSET_ALREADY_ATTACHED',
|
||||||
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
|
ASSET_DETACH_FAILED: 'ASSET_DETACH_FAILED',
|
||||||
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
|
ASSET_USAGE_FETCH_FAILED: 'ASSET_USAGE_FETCH_FAILED',
|
||||||
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
|
ASSET_EXPORT_FAILED: 'ASSET_EXPORT_FAILED',
|
||||||
|
|||||||
Reference in New Issue
Block a user