feat: bulk asset metadata endpoints (#25133)

This commit is contained in:
Jason Rasmussen
2026-01-08 14:52:16 -05:00
committed by GitHub
parent 109c79125d
commit a2ba36c16d
29 changed files with 1325 additions and 200 deletions
+70 -22
View File
@@ -79,6 +79,74 @@ describe(AssetController.name, () => {
});
});
describe('PUT /assets/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid assetId', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
});
it('should require a key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
),
);
});
it('should work', async () => {
const { status } = await request(ctx.getHttpServer())
.put('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] });
expect(status).toBe(200);
});
});
describe('DELETE /assets/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid assetId', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
});
it('should require a key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
),
);
});
it('should work', async () => {
const { status } = await request(ctx.getHttpServer())
.delete('/assets/metadata')
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] });
expect(status).toBe(204);
});
});
describe('PUT /assets/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/123`);
@@ -169,12 +237,10 @@ describe(AssetController.name, () => {
it('should require each item to have a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'someKey' }] });
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
),
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
);
});
@@ -224,16 +290,6 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
),
);
});
});
describe('DELETE /assets/:id/metadata/:key', () => {
@@ -247,13 +303,5 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
);
});
});
});
@@ -7,6 +7,9 @@ import {
AssetBulkUpdateDto,
AssetCopyDto,
AssetJobsDto,
AssetMetadataBulkDeleteDto,
AssetMetadataBulkResponseDto,
AssetMetadataBulkUpsertDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
AssetMetadataUpsertDto,
@@ -120,6 +123,32 @@ export class AssetController {
return this.service.copy(auth, dto);
}
@Put('metadata')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({
summary: 'Upsert asset metadata',
description: 'Upsert metadata key-value pairs for multiple assets.',
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
})
updateBulkAssetMetadata(
@Auth() auth: AuthDto,
@Body() dto: AssetMetadataBulkUpsertDto,
): Promise<AssetMetadataBulkResponseDto[]> {
return this.service.upsertBulkMetadata(auth, dto);
}
@Delete('metadata')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete asset metadata',
description: 'Delete metadata key-value pairs for multiple assets.',
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
})
deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise<void> {
return this.service.deleteBulkMetadata(auth, dto);
}
@Put(':id')
@Authenticated({ permission: Permission.AssetUpdate })
@Endpoint({
+43 -12
View File
@@ -17,9 +17,9 @@ import {
ValidateNested,
} from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@IsNotEmpty()
@@ -142,8 +142,8 @@ export class AssetMetadataRouteParams {
@ValidateUUID()
id!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
}
export class AssetMetadataUpsertDto {
@@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto {
}
export class AssetMetadataUpsertItemDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
@IsObject()
value!: object;
}
export class AssetMetadataMobileAppDto {
@IsString()
@Optional()
iCloudId?: string;
export class AssetMetadataBulkUpsertDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkUpsertItemDto)
items!: AssetMetadataBulkUpsertItemDto[];
}
export class AssetMetadataBulkUpsertItemDto {
@ValidateUUID()
assetId!: string;
@ValidateString()
key!: string;
@IsObject()
value!: object;
}
export class AssetMetadataBulkDeleteDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetMetadataBulkDeleteItemDto)
items!: AssetMetadataBulkDeleteItemDto[];
}
export class AssetMetadataBulkDeleteItemDto {
@ValidateUUID()
assetId!: string;
@ValidateString()
key!: string;
}
export class AssetMetadataResponseDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@ValidateString()
key!: string;
value!: object;
updatedAt!: Date;
}
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
assetId!: string;
}
export class AssetCopyDto {
@ValidateUUID()
sourceId!: string;
+2 -5
View File
@@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AlbumUserRole,
AssetMetadataKey,
AssetOrder,
AssetType,
AssetVisibility,
@@ -167,16 +166,14 @@ export class SyncAssetExifV1 {
@ExtraModel()
export class SyncAssetMetadataV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
key!: string;
value!: object;
}
@ExtraModel()
export class SyncAssetMetadataDeleteV1 {
assetId!: string;
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
key!: string;
}
@ExtraModel()
+8
View File
@@ -76,6 +76,14 @@ where
"assetId" = $1
and "key" = $2
-- AssetRepository.deleteBulkMetadata
begin
delete from "asset_metadata"
where
"assetId" = $1
and "key" = $2
commit
-- AssetRepository.getByDayOfYear
with
"res" as (
+31 -4
View File
@@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
anyUuid,
@@ -256,7 +257,7 @@ export class AssetRepository {
.execute();
}
upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) {
upsertMetadata(id: string, items: Array<{ key: string; value: object }>) {
return this.db
.insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item })))
@@ -269,8 +270,21 @@ export class AssetRepository {
.execute();
}
upsertBulkMetadata(items: Insertable<AssetMetadataTable>[]) {
return this.db
.insertInto('asset_metadata')
.values(items)
.onConflict((oc) =>
oc
.columns(['assetId', 'key'])
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
)
.returning(['assetId', 'key', 'value', 'updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
getMetadataByKey(assetId: string, key: string) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
@@ -280,10 +294,23 @@ export class AssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
async deleteMetadataByKey(id: string, key: string) {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] })
async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) {
if (items.length === 0) {
return;
}
await this.db.transaction().execute(async (tx) => {
for (const { assetId, key } of items) {
await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute();
}
});
}
create(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}
@@ -1,5 +1,4 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { AssetMetadataKey } from 'src/enum';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('asset_metadata_audit')
@@ -11,7 +10,7 @@ export class AssetMetadataAuditTable {
assetId!: string;
@Column({ index: true })
key!: AssetMetadataKey;
key!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
@@ -32,7 +32,7 @@ export class AssetMetadataTable {
assetId!: string;
@PrimaryColumn({ type: 'character varying' })
key!: AssetMetadataKey;
key!: AssetMetadataKey | string;
@Column({ type: 'jsonb' })
value!: object;
+16 -12
View File
@@ -11,6 +11,9 @@ import {
AssetCopyDto,
AssetJobName,
AssetJobsDto,
AssetMetadataBulkDeleteDto,
AssetMetadataBulkResponseDto,
AssetMetadataBulkUpsertDto,
AssetMetadataResponseDto,
AssetMetadataUpsertDto,
AssetStatsDto,
@@ -19,16 +22,7 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import {
AssetFileType,
AssetMetadataKey,
AssetStatus,
AssetVisibility,
JobName,
JobStatus,
Permission,
QueueName,
} from 'src/enum';
import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
@@ -381,12 +375,17 @@ export class AssetService extends BaseService {
return this.ocrRepository.getByAssetId(id);
}
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
return this.assetRepository.upsertBulkMetadata(dto.items);
}
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.upsertMetadata(id, dto.items);
}
async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<AssetMetadataResponseDto> {
async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise<AssetMetadataResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const item = await this.assetRepository.getMetadataByKey(id, key);
@@ -396,11 +395,16 @@ export class AssetService extends BaseService {
return item;
}
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
return this.assetRepository.deleteMetadataByKey(id, key);
}
async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) });
await this.assetRepository.deleteBulkMetadata(dto.items);
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });