mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
@@ -3,7 +3,6 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
AddUsersDto,
|
||||
AlbumInfoDto,
|
||||
AlbumResponseDto,
|
||||
AlbumsAddAssetsDto,
|
||||
AlbumsAddAssetsResponseDto,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
@@ -65,12 +65,8 @@ export class AlbumController {
|
||||
description: 'Retrieve information about a specific album by its ID.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
getAlbumInfo(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AlbumInfoDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.get(auth, id, dto);
|
||||
getAlbumInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AlbumResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -102,6 +98,17 @@ export class AlbumController {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
|
||||
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
|
||||
@Get(':id/map-markers')
|
||||
@Endpoint({
|
||||
summary: 'Retrieve album map markers',
|
||||
description: 'Retrieve map marker information for a specific album by its ID.',
|
||||
history: new HistoryBuilder().added('v3'),
|
||||
})
|
||||
getAlbumMapMarkers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MapMarkerResponseDto[]> {
|
||||
return this.service.getMapMarkers(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id/assets')
|
||||
@Authenticated({ permission: Permission.AlbumAssetCreate })
|
||||
@Endpoint({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
@@ -20,7 +20,7 @@ export class ApiKeyController {
|
||||
description: 'Creates a new API key. It will be limited to the permissions specified.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
createApiKey(@Auth() auth: AuthDto, @Body() dto: ApiKeyCreateDto): Promise<ApiKeyCreateResponseDto> {
|
||||
return this.service.create(auth, dto);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ApiKeyController {
|
||||
description: 'Retrieve all API keys of the current user.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
|
||||
getApiKeys(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ApiKeyController {
|
||||
description: 'Retrieve the API key that is used to access this endpoint.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
|
||||
async getMyApiKey(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto> {
|
||||
return this.service.getMine(auth);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export class ApiKeyController {
|
||||
description: 'Retrieve an API key by its ID. The current user must own this API key.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
|
||||
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ApiKeyResponseDto> {
|
||||
return this.service.getById(auth, id);
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ export class ApiKeyController {
|
||||
updateApiKey(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: APIKeyUpdateDto,
|
||||
): Promise<APIKeyResponseDto> {
|
||||
@Body() dto: ApiKeyUpdateDto,
|
||||
): Promise<ApiKeyResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { automock, ControllerContext, controllerSetup, mockBaseService } from 't
|
||||
|
||||
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
|
||||
const dto: Record<string, any> = {
|
||||
deviceAssetId: 'example-image',
|
||||
deviceId: 'TEST',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
isFavorite: 'false',
|
||||
@@ -87,28 +85,6 @@ describe(AssetMediaController.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `deviceAssetId`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[deviceAssetId] Invalid input: expected string, received undefined']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `deviceId`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['[deviceId] Invalid input: expected string, received undefined']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `fileCreatedAt`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
|
||||
@@ -21,14 +21,12 @@ import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatus,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaSize,
|
||||
CheckExistingAssetsDto,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -179,21 +177,6 @@ export class AssetMediaController {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Endpoint({
|
||||
summary: 'Check existing assets',
|
||||
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
checkExistingAssets(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
return this.service.checkExistingAssets(auth, dto);
|
||||
}
|
||||
|
||||
@Post('bulk-upload-check')
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Endpoint({
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
AssetMetadataUpsertDto,
|
||||
AssetStatsDto,
|
||||
AssetStatsResponseDto,
|
||||
DeviceIdDto,
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -31,17 +30,6 @@ import { UUIDParamDto } from 'src/validation';
|
||||
export class AssetController {
|
||||
constructor(private service: AssetService) {}
|
||||
|
||||
@Get('/device/:deviceId')
|
||||
@Endpoint({
|
||||
summary: 'Retrieve assets by device ID',
|
||||
description: 'Get all asset of a device that are in the database, ID only.',
|
||||
history: new HistoryBuilder().added('v1').deprecated('v2'),
|
||||
})
|
||||
@Authenticated()
|
||||
getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) {
|
||||
return this.service.getUserAssetsByDeviceId(auth, deviceId);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
@Authenticated({ permission: Permission.AssetStatistics })
|
||||
@Endpoint({
|
||||
|
||||
@@ -2,17 +2,8 @@ import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetDeltaSyncDto,
|
||||
AssetDeltaSyncResponseDto,
|
||||
AssetFullSyncDto,
|
||||
SyncAckDeleteDto,
|
||||
SyncAckDto,
|
||||
SyncAckSetDto,
|
||||
SyncStreamDto,
|
||||
} from 'src/dtos/sync.dto';
|
||||
import { SyncAckDeleteDto, SyncAckDto, SyncAckSetDto, SyncStreamDto } from 'src/dtos/sync.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
@@ -26,30 +17,6 @@ export class SyncController {
|
||||
private errorService: GlobalExceptionFilter,
|
||||
) {}
|
||||
|
||||
@Post('full-sync')
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Get full sync for user',
|
||||
description: 'Retrieve all assets for a full synchronization for the authenticated user.',
|
||||
history: new HistoryBuilder().added('v1').deprecated('v2'),
|
||||
})
|
||||
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
|
||||
return this.service.getFullSync(auth, dto);
|
||||
}
|
||||
|
||||
@Post('delta-sync')
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Get delta sync for user',
|
||||
description: 'Retrieve changed assets since the last sync for the authenticated user.',
|
||||
history: new HistoryBuilder().added('v1').deprecated('v2'),
|
||||
})
|
||||
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
|
||||
return this.service.getDeltaSync(auth, dto);
|
||||
}
|
||||
|
||||
@Post('stream')
|
||||
@Authenticated({ permission: Permission.SyncStream })
|
||||
@Header('Content-Type', 'application/jsonlines+json')
|
||||
|
||||
@@ -64,7 +64,7 @@ export class UserController {
|
||||
@Authenticated({ permission: Permission.UserUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Update current user',
|
||||
description: 'Update the current user making teh API request.',
|
||||
description: 'Update the current user making the API request.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
|
||||
@@ -114,8 +114,6 @@ export type Asset = {
|
||||
id: string;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
checksumAlgorithm: ChecksumAlgorithm;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
isExternal: boolean;
|
||||
@@ -333,8 +331,6 @@ export const columns = {
|
||||
'asset.id',
|
||||
'asset.checksum',
|
||||
'asset.checksumAlgorithm',
|
||||
'asset.deviceAssetId',
|
||||
'asset.deviceId',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.isExternal',
|
||||
|
||||
@@ -10,13 +10,13 @@ describe('mapAlbum', () => {
|
||||
.asset({ localDateTime: endDate }, (builder) => builder.exif())
|
||||
.asset({ localDateTime: startDate }, (builder) => builder.exif())
|
||||
.build();
|
||||
const dto = mapAlbum(getForAlbum(album), false);
|
||||
const dto = mapAlbum(getForAlbum(album));
|
||||
expect(dto.startDate).toEqual(startDate.toISOString());
|
||||
expect(dto.endDate).toEqual(endDate.toISOString());
|
||||
});
|
||||
|
||||
it('should not set start and end dates for empty assets', () => {
|
||||
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
|
||||
const dto = mapAlbum(getForAlbum(AlbumFactory.create()));
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import _ from 'lodash';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetResponseSchema, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
|
||||
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
@@ -12,12 +11,6 @@ import { asDateString } from 'src/utils/date';
|
||||
import { stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const AlbumInfoSchema = z
|
||||
.object({
|
||||
withoutAssets: stringToBool.optional().describe('Exclude assets from response'),
|
||||
})
|
||||
.meta({ id: 'AlbumInfoDto' });
|
||||
|
||||
const AlbumUserAddSchema = z
|
||||
.object({
|
||||
userId: z.uuidv4().describe('User ID'),
|
||||
@@ -122,7 +115,6 @@ export const AlbumResponseSchema = z
|
||||
shared: z.boolean().describe('Is shared album'),
|
||||
albumUsers: z.array(AlbumUserResponseSchema),
|
||||
hasSharedLink: z.boolean().describe('Has shared link'),
|
||||
assets: z.array(AssetResponseSchema),
|
||||
owner: UserResponseSchema,
|
||||
assetCount: z.int().min(0).describe('Number of assets'),
|
||||
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
|
||||
@@ -141,7 +133,6 @@ export const AlbumResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'AlbumResponseDto' });
|
||||
|
||||
export class AlbumInfoDto extends createZodDto(AlbumInfoSchema) {}
|
||||
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
|
||||
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
|
||||
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}
|
||||
@@ -170,11 +161,7 @@ export type MapAlbumDto = {
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
export const mapAlbum = (
|
||||
entity: MaybeDehydrated<MapAlbumDto>,
|
||||
withAssets: boolean,
|
||||
auth?: AuthDto,
|
||||
): AlbumResponseDto => {
|
||||
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
@@ -215,12 +202,8 @@ export const mapAlbum = (
|
||||
hasSharedLink,
|
||||
startDate: asDateString(startDate),
|
||||
endDate: asDateString(endDate),
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
order: entity.order,
|
||||
};
|
||||
};
|
||||
|
||||
export const mapAlbumWithAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
|
||||
|
||||
@@ -5,21 +5,21 @@ import z from 'zod';
|
||||
|
||||
const PermissionSchema = z.enum(Permission).describe('List of permissions').meta({ id: 'Permission' });
|
||||
|
||||
const APIKeyCreateSchema = z
|
||||
const ApiKeyCreateSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('API key name'),
|
||||
permissions: z.array(PermissionSchema).min(1).describe('List of permissions'),
|
||||
})
|
||||
.meta({ id: 'APIKeyCreateDto' });
|
||||
.meta({ id: 'ApiKeyCreateDto' });
|
||||
|
||||
const APIKeyUpdateSchema = z
|
||||
const ApiKeyUpdateSchema = z
|
||||
.object({
|
||||
name: z.string().optional().describe('API key name'),
|
||||
permissions: z.array(PermissionSchema).min(1).optional().describe('List of permissions'),
|
||||
})
|
||||
.meta({ id: 'APIKeyUpdateDto' });
|
||||
.meta({ id: 'ApiKeyUpdateDto' });
|
||||
|
||||
const APIKeyResponseSchema = z
|
||||
const ApiKeyResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('API key ID'),
|
||||
name: z.string().describe('API key name'),
|
||||
@@ -27,16 +27,16 @@ const APIKeyResponseSchema = z
|
||||
updatedAt: isoDatetimeToDate.describe('Last update date'),
|
||||
permissions: z.array(PermissionSchema).describe('List of permissions'),
|
||||
})
|
||||
.meta({ id: 'APIKeyResponseDto' });
|
||||
.meta({ id: 'ApiKeyResponseDto' });
|
||||
|
||||
const APIKeyCreateResponseSchema = z
|
||||
const ApiKeyCreateResponseSchema = z
|
||||
.object({
|
||||
secret: z.string().describe('API key secret (only shown once)'),
|
||||
apiKey: APIKeyResponseSchema,
|
||||
apiKey: ApiKeyResponseSchema,
|
||||
})
|
||||
.meta({ id: 'APIKeyCreateResponseDto' });
|
||||
.meta({ id: 'ApiKeyCreateResponseDto' });
|
||||
|
||||
export class APIKeyCreateDto extends createZodDto(APIKeyCreateSchema) {}
|
||||
export class APIKeyUpdateDto extends createZodDto(APIKeyUpdateSchema) {}
|
||||
export class APIKeyResponseDto extends createZodDto(APIKeyResponseSchema) {}
|
||||
export class APIKeyCreateResponseDto extends createZodDto(APIKeyCreateResponseSchema) {}
|
||||
export class ApiKeyCreateDto extends createZodDto(ApiKeyCreateSchema) {}
|
||||
export class ApiKeyUpdateDto extends createZodDto(ApiKeyUpdateSchema) {}
|
||||
export class ApiKeyResponseDto extends createZodDto(ApiKeyResponseSchema) {}
|
||||
export class ApiKeyCreateResponseDto extends createZodDto(ApiKeyCreateResponseSchema) {}
|
||||
|
||||
@@ -49,12 +49,5 @@ const AssetBulkUploadCheckResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'AssetBulkUploadCheckResponseDto' });
|
||||
|
||||
const CheckExistingAssetsResponseSchema = z
|
||||
.object({
|
||||
existingIds: z.array(z.string()).describe('Existing asset IDs'),
|
||||
})
|
||||
.meta({ id: 'CheckExistingAssetsResponseDto' });
|
||||
|
||||
export class AssetMediaResponseDto extends createZodDto(AssetMediaResponseSchema) {}
|
||||
export class AssetBulkUploadCheckResponseDto extends createZodDto(AssetBulkUploadCheckResponseSchema) {}
|
||||
export class CheckExistingAssetsResponseDto extends createZodDto(CheckExistingAssetsResponseSchema) {}
|
||||
|
||||
@@ -36,8 +36,6 @@ export enum UploadFieldName {
|
||||
}
|
||||
|
||||
const AssetMediaBaseSchema = z.object({
|
||||
deviceAssetId: z.string().describe('Device asset ID'),
|
||||
deviceId: z.string().describe('Device ID'),
|
||||
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
|
||||
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
|
||||
duration: z.string().optional().describe('Duration (for videos)'),
|
||||
@@ -71,14 +69,6 @@ const AssetBulkUploadCheckSchema = z
|
||||
})
|
||||
.meta({ id: 'AssetBulkUploadCheckDto' });
|
||||
|
||||
const CheckExistingAssetsSchema = z
|
||||
.object({
|
||||
deviceAssetIds: z.array(z.string()).min(1).describe('Device asset IDs to check'),
|
||||
deviceId: z.string().describe('Device ID'),
|
||||
})
|
||||
.meta({ id: 'CheckExistingAssetsDto' });
|
||||
|
||||
export class AssetMediaOptionsDto extends createZodDto(AssetMediaOptionsSchema) {}
|
||||
export class AssetMediaCreateDto extends createZodDto(AssetMediaCreateSchema) {}
|
||||
export class AssetBulkUploadCheckDto extends createZodDto(AssetBulkUploadCheckSchema) {}
|
||||
export class CheckExistingAssetsDto extends createZodDto(CheckExistingAssetsSchema) {}
|
||||
|
||||
@@ -72,8 +72,6 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
|
||||
.string()
|
||||
.meta({ format: 'date-time' })
|
||||
.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
|
||||
deviceAssetId: z.string().describe('Device asset ID'),
|
||||
deviceId: z.string().describe('Device ID'),
|
||||
ownerId: z.string().describe('Owner user ID'),
|
||||
owner: UserResponseSchema.optional(),
|
||||
libraryId: z
|
||||
@@ -137,8 +135,6 @@ export type MapAsset = {
|
||||
status: AssetStatus;
|
||||
checksum: Buffer<ArrayBufferLike>;
|
||||
checksumAlgorithm: ChecksumAlgorithm;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||
@@ -239,10 +235,8 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||
deviceId: entity.deviceId,
|
||||
libraryId: entity.libraryId,
|
||||
type: entity.type,
|
||||
originalPath: entity.originalPath,
|
||||
|
||||
@@ -6,12 +6,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { IsNotSiblingOf, isoDatetimeToDate, latitudeSchema, longitudeSchema, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const DeviceIdSchema = z
|
||||
.object({
|
||||
deviceId: z.string().describe('Device ID'),
|
||||
})
|
||||
.meta({ id: 'DeviceIdDto' });
|
||||
|
||||
const UpdateAssetBaseSchema = z
|
||||
.object({
|
||||
isFavorite: z.boolean().optional().describe('Mark as favorite'),
|
||||
@@ -182,7 +176,6 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
};
|
||||
};
|
||||
|
||||
export class DeviceIdDto extends createZodDto(DeviceIdSchema) {}
|
||||
export class AssetBulkUpdateDto extends createZodDto(AssetBulkUpdateSchema) {}
|
||||
export class UpdateAssetDto extends createZodDto(UpdateAssetSchema) {}
|
||||
export class AssetBulkDeleteDto extends createZodDto(AssetBulkDeleteSchema) {}
|
||||
|
||||
@@ -9,7 +9,6 @@ import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
libraryId: z.uuidv4().nullish().describe('Library ID to filter by'),
|
||||
deviceId: z.string().optional().describe('Device ID to filter by'),
|
||||
type: AssetTypeSchema.optional(),
|
||||
isEncoded: z.boolean().optional().describe('Filter by encoded status'),
|
||||
isFavorite: z.boolean().optional().describe('Filter by favorite status'),
|
||||
@@ -68,7 +67,6 @@ const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
|
||||
const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
id: z.uuidv4().optional().describe('Filter by asset ID'),
|
||||
deviceAssetId: z.string().optional().describe('Filter by device asset ID'),
|
||||
description: z.string().trim().optional().describe('Filter by description text'),
|
||||
checksum: z.string().optional().describe('Filter by file checksum'),
|
||||
originalFileName: z.string().trim().optional().describe('Filter by original file name'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
||||
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkTypeSchema } from 'src/enum';
|
||||
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
|
||||
@@ -96,7 +96,7 @@ export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetad
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
AlbumUserRoleSchema,
|
||||
@@ -17,36 +16,6 @@ import {
|
||||
import { isoDatetimeToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const AssetFullSyncSchema = z
|
||||
.object({
|
||||
lastId: z.uuidv4().optional().describe('Last asset ID (pagination)'),
|
||||
updatedUntil: isoDatetimeToDate.describe('Sync assets updated until this date'),
|
||||
limit: z.int().min(1).describe('Maximum number of assets to return'),
|
||||
userId: z.uuidv4().optional().describe('Filter by user ID'),
|
||||
})
|
||||
.meta({ id: 'AssetFullSyncDto' });
|
||||
|
||||
const AssetDeltaSyncSchema = z
|
||||
.object({
|
||||
updatedAfter: isoDatetimeToDate.describe('Sync assets updated after this date'),
|
||||
userIds: z.array(z.uuidv4()).describe('User IDs to sync'),
|
||||
})
|
||||
.meta({ id: 'AssetDeltaSyncDto' });
|
||||
|
||||
export class AssetFullSyncDto extends createZodDto(AssetFullSyncSchema) {}
|
||||
export class AssetDeltaSyncDto extends createZodDto(AssetDeltaSyncSchema) {}
|
||||
|
||||
const AssetDeltaSyncResponseSchema = z
|
||||
.object({
|
||||
needsFullSync: z.boolean().describe('Whether full sync is needed'),
|
||||
upserted: z.array(AssetResponseSchema),
|
||||
deleted: z.array(z.string()).describe('Deleted asset IDs'),
|
||||
})
|
||||
.describe('Asset delta sync response')
|
||||
.meta({ id: 'AssetDeltaSyncResponseDto' });
|
||||
|
||||
export class AssetDeltaSyncResponseDto extends createZodDto(AssetDeltaSyncResponseSchema) {}
|
||||
|
||||
export const extraSyncModels: Function[] = [];
|
||||
|
||||
const ExtraModel = (): ClassDecorator => {
|
||||
|
||||
@@ -88,21 +88,6 @@ export enum AssetOrder {
|
||||
|
||||
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
|
||||
|
||||
export enum DatabaseAction {
|
||||
Create = 'CREATE',
|
||||
Update = 'UPDATE',
|
||||
Delete = 'DELETE',
|
||||
}
|
||||
|
||||
export const DatabaseActionSchema = z.enum(DatabaseAction).describe('Database action').meta({ id: 'DatabaseAction' });
|
||||
|
||||
export enum EntityType {
|
||||
Asset = 'ASSET',
|
||||
Album = 'ALBUM',
|
||||
}
|
||||
|
||||
export const EntityTypeSchema = z.enum(EntityType).describe('Entity type').meta({ id: 'EntityType' });
|
||||
|
||||
export enum MemoryType {
|
||||
/** pictures taken on this day X years ago */
|
||||
OnThisDay = 'on_this_day',
|
||||
@@ -761,7 +746,6 @@ export enum JobName {
|
||||
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
DatabaseBackup = 'DatabaseBackup',
|
||||
|
||||
@@ -250,8 +250,6 @@ select
|
||||
"asset"."id",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."deviceAssetId",
|
||||
"asset"."deviceId",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
|
||||
@@ -242,17 +242,6 @@ where
|
||||
limit
|
||||
$3
|
||||
|
||||
-- AssetRepository.getAllByDeviceId
|
||||
select
|
||||
"deviceAssetId"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"ownerId" = $1::uuid
|
||||
and "deviceId" = $2
|
||||
and "visibility" != $3
|
||||
and "deletedAt" is null
|
||||
|
||||
-- AssetRepository.getLivePhotoCount
|
||||
select
|
||||
count(*) as "count"
|
||||
@@ -312,9 +301,8 @@ limit
|
||||
-- AssetRepository.updateAll
|
||||
update "asset"
|
||||
set
|
||||
"deviceId" = $1
|
||||
where
|
||||
"id" = any ($2::uuid[])
|
||||
"id" = any ($1::uuid[])
|
||||
|
||||
-- AssetRepository.getByChecksum
|
||||
select
|
||||
@@ -497,63 +485,6 @@ where
|
||||
limit
|
||||
$5
|
||||
|
||||
-- AssetRepository.getAllForUserFullSync
|
||||
select
|
||||
"asset".*,
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
to_json("stacked_assets") as "stack"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
left join "stack" on "stack"."id" = "asset"."stackId"
|
||||
left join lateral (
|
||||
select
|
||||
"stack".*,
|
||||
count("stacked") as "assetCount"
|
||||
from
|
||||
"asset" as "stacked"
|
||||
where
|
||||
"stacked"."stackId" = "stack"."id"
|
||||
group by
|
||||
"stack"."id"
|
||||
) as "stacked_assets" on "stack"."id" is not null
|
||||
where
|
||||
"asset"."ownerId" = $1::uuid
|
||||
and "asset"."visibility" != $2
|
||||
and "asset"."updatedAt" <= $3
|
||||
and "asset"."id" > $4
|
||||
order by
|
||||
"asset"."id"
|
||||
limit
|
||||
$5
|
||||
|
||||
-- AssetRepository.getChangedDeltaSync
|
||||
select
|
||||
"asset".*,
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
to_json("stacked_assets") as "stack"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
left join "stack" on "stack"."id" = "asset"."stackId"
|
||||
left join lateral (
|
||||
select
|
||||
"stack".*,
|
||||
count("stacked") as "assetCount"
|
||||
from
|
||||
"asset" as "stacked"
|
||||
where
|
||||
"stacked"."stackId" = "stack"."id"
|
||||
group by
|
||||
"stack"."id"
|
||||
) as "stacked_assets" on "stack"."id" is not null
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset"."visibility" != $2
|
||||
and "asset"."updatedAt" > $3
|
||||
limit
|
||||
$4
|
||||
|
||||
-- AssetRepository.detectOfflineExternalAssets
|
||||
update "asset"
|
||||
set
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AuditRepository.getAfter
|
||||
select distinct
|
||||
on ("audit"."entityId", "audit"."entityType") "audit"."entityId"
|
||||
from
|
||||
"audit"
|
||||
where
|
||||
"audit"."createdAt" > $1
|
||||
and "audit"."action" = $2
|
||||
and "audit"."entityType" = $3
|
||||
and "audit"."ownerId" in ($4)
|
||||
order by
|
||||
"audit"."entityId" desc,
|
||||
"audit"."entityType" desc,
|
||||
"audit"."createdAt" desc
|
||||
@@ -1,5 +1,25 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- MapRepository.getAlbumMapMarkers
|
||||
select
|
||||
"id",
|
||||
"asset_exif"."latitude" as "lat",
|
||||
"asset_exif"."longitude" as "lon",
|
||||
"asset_exif"."city",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."country"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
and "asset_exif"."latitude" is not null
|
||||
and "asset_exif"."longitude" is not null
|
||||
inner join "album_asset" on "asset"."id" = "album_asset"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "album_asset"."albumId" = $1
|
||||
order by
|
||||
"fileCreatedAt" desc
|
||||
|
||||
-- MapRepository.getMapMarkers
|
||||
select
|
||||
"id",
|
||||
@@ -14,8 +34,8 @@ from
|
||||
and "asset_exif"."latitude" is not null
|
||||
and "asset_exif"."longitude" is not null
|
||||
where
|
||||
"asset"."visibility" = $1
|
||||
and "deletedAt" is null
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" = $1
|
||||
and (
|
||||
"ownerId" in ($2)
|
||||
or exists (
|
||||
|
||||
@@ -106,19 +106,6 @@ interface AssetExploreFieldOptions {
|
||||
minAssetsPerField: number;
|
||||
}
|
||||
|
||||
interface AssetFullSyncOptions {
|
||||
ownerId: string;
|
||||
lastId?: string;
|
||||
updatedUntil: Date;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface AssetDeltaSyncOptions {
|
||||
userIds: string[];
|
||||
updatedAfter: Date;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface AssetGetByChecksumOptions {
|
||||
ownerId: string;
|
||||
checksum: Buffer;
|
||||
@@ -461,18 +448,6 @@ export class AssetRepository {
|
||||
await this.db.deleteFrom('asset').where('ownerId', '=', ownerId).execute();
|
||||
}
|
||||
|
||||
async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]> {
|
||||
const assets = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['deviceAssetId'])
|
||||
.where('deviceAssetId', 'in', deviceAssetIds)
|
||||
.where('deviceId', '=', deviceId)
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.execute();
|
||||
|
||||
return assets.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
||||
return this.db
|
||||
@@ -484,27 +459,6 @@ export class AssetRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by device's Id on the database
|
||||
* @param ownerId
|
||||
* @param deviceId
|
||||
*
|
||||
* @returns Promise<string[]> - Array of assetIds belong to the device
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
|
||||
const items = await this.db
|
||||
.selectFrom('asset')
|
||||
.select(['deviceAssetId'])
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('deviceId', '=', deviceId)
|
||||
.where('visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
return items.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getLivePhotoCount(motionId: string): Promise<number> {
|
||||
const [{ count }] = await this.db
|
||||
@@ -581,7 +535,7 @@ export class AssetRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
|
||||
@GenerateSql({ params: [[DummyValue.UUID], {}] })
|
||||
@Chunked()
|
||||
async updateAll(ids: string[], options: Updateable<AssetTable>): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
@@ -905,70 +859,6 @@ export class AssetRepository {
|
||||
return { fieldName: 'exifInfo.city', items };
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
ownerId: DummyValue.UUID,
|
||||
lastId: DummyValue.UUID,
|
||||
updatedUntil: DummyValue.DATE,
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
getAllForUserFullSync(options: AssetFullSyncOptions) {
|
||||
const { ownerId, lastId, updatedUntil, limit } = options;
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.leftJoin('stack', 'stack.id', 'asset.stackId')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset as stacked')
|
||||
.selectAll('stack')
|
||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.groupBy('stack.id')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
|
||||
.where('asset.ownerId', '=', asUuid(ownerId))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.updatedAt', '<=', updatedUntil)
|
||||
.$if(!!lastId, (qb) => qb.where('asset.id', '>', lastId!))
|
||||
.orderBy('asset.id')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
|
||||
async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.$call(withExif)
|
||||
.leftJoin('stack', 'stack.id', 'asset.stackId')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset as stacked')
|
||||
.selectAll('stack')
|
||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.groupBy('stack.id')
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
|
||||
.where('asset.ownerId', '=', anyUuid(options.userIds))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.updatedAt', '>', options.updatedAfter)
|
||||
.limit(options.limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(
|
||||
file: Pick<
|
||||
Insertable<AssetFileTable>,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DatabaseAction, EntityType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
export interface AuditSearch {
|
||||
action?: DatabaseAction;
|
||||
entityType?: EntityType;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
DummyValue.DATE,
|
||||
{ action: DatabaseAction.Create, entityType: EntityType.Asset, userIds: [DummyValue.UUID] },
|
||||
],
|
||||
})
|
||||
async getAfter(since: Date, options: AuditSearch): Promise<string[]> {
|
||||
const records = await this.db
|
||||
.selectFrom('audit')
|
||||
.where('audit.createdAt', '>', since)
|
||||
.$if(!!options.action, (qb) => qb.where('audit.action', '=', options.action!))
|
||||
.$if(!!options.entityType, (qb) => qb.where('audit.entityType', '=', options.entityType!))
|
||||
.where('audit.ownerId', 'in', options.userIds)
|
||||
.distinctOn(['audit.entityId', 'audit.entityType'])
|
||||
.orderBy('audit.entityId', 'desc')
|
||||
.orderBy('audit.entityType', 'desc')
|
||||
.orderBy('audit.createdAt', 'desc')
|
||||
.select('audit.entityId')
|
||||
.execute();
|
||||
|
||||
return records.map(({ entityId }) => entityId);
|
||||
}
|
||||
|
||||
async removeBefore(before: Date): Promise<void> {
|
||||
await this.db.deleteFrom('audit').where('createdAt', '<', before).execute();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -56,7 +55,6 @@ export const repositories = [
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserRepository,
|
||||
AuditRepository,
|
||||
ApiKeyRepository,
|
||||
AppRepository,
|
||||
AssetRepository,
|
||||
|
||||
@@ -76,29 +76,21 @@ export class MapRepository {
|
||||
this.logger.log('Geodata import completed');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAlbumMapMarkers(albumId: string) {
|
||||
return this.mapMarkersQuery()
|
||||
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
|
||||
.where('album_asset.albumId', '=', albumId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
|
||||
getMapMarkers(
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
|
||||
) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', (builder) =>
|
||||
builder
|
||||
.onRef('asset.id', '=', 'asset_exif.assetId')
|
||||
.on('asset_exif.latitude', 'is not', null)
|
||||
.on('asset_exif.longitude', 'is not', null),
|
||||
)
|
||||
.select([
|
||||
'id',
|
||||
'asset_exif.latitude as lat',
|
||||
'asset_exif.longitude as lon',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
])
|
||||
.$narrowType<{ lat: NotNull; lon: NotNull }>()
|
||||
return this.mapMarkersQuery()
|
||||
.$if(isArchived === true, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
@@ -113,7 +105,6 @@ export class MapRepository {
|
||||
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
|
||||
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
|
||||
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.where((eb) => {
|
||||
const expression: Expression<SqlBool>[] = [];
|
||||
|
||||
@@ -134,10 +125,31 @@ export class MapRepository {
|
||||
|
||||
return eb.or(expression);
|
||||
})
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
private mapMarkersQuery() {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', (builder) =>
|
||||
builder
|
||||
.onRef('asset.id', '=', 'asset_exif.assetId')
|
||||
.on('asset_exif.latitude', 'is not', null)
|
||||
.on('asset_exif.longitude', 'is not', null),
|
||||
)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.select([
|
||||
'id',
|
||||
'asset_exif.latitude as lat',
|
||||
'asset_exif.longitude as lon',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
])
|
||||
.$narrowType<{ lat: NotNull; lon: NotNull }>();
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@ import { isValidInteger } from 'src/validation';
|
||||
|
||||
export interface SearchAssetIdOptions {
|
||||
checksum?: Buffer;
|
||||
deviceAssetId?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SearchUserIdOptions {
|
||||
deviceId?: string;
|
||||
libraryId?: string | null;
|
||||
userIds?: string[];
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { AuditTable } from 'src/schema/tables/audit.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||
@@ -98,7 +97,6 @@ export class ImmichDatabase {
|
||||
AssetOcrTable,
|
||||
AssetTable,
|
||||
AssetFileTable,
|
||||
AuditTable,
|
||||
AssetExifTable,
|
||||
FaceSearchTable,
|
||||
GeodataPlacesTable,
|
||||
@@ -197,8 +195,6 @@ export interface DB {
|
||||
asset_ocr: AssetOcrTable;
|
||||
ocr_search: OcrSearchTable;
|
||||
|
||||
audit: AuditTable;
|
||||
|
||||
face_search: FaceSearchTable;
|
||||
|
||||
geodata_places: GeodataPlacesTable;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "audit";`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TABLE "audit" (
|
||||
"id" serial NOT NULL,
|
||||
"entityType" character varying NOT NULL,
|
||||
"entityId" uuid NOT NULL,
|
||||
"action" character varying NOT NULL,
|
||||
"ownerId" uuid NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "audit_ownerId_createdAt_idx" ON "audit" ("ownerId", "createdAt");`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "deviceAssetId";`.execute(db);
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "deviceId";`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" ADD "deviceAssetId" character varying NOT NULL;`.execute(db);
|
||||
await sql`ALTER TABLE "asset" ADD "deviceId" character varying NOT NULL;`.execute(db);
|
||||
}
|
||||
@@ -65,15 +65,9 @@ export class AssetTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||
ownerId!: string;
|
||||
|
||||
@Column()
|
||||
deviceId!: string;
|
||||
|
||||
@Column()
|
||||
type!: AssetType;
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { DatabaseAction, EntityType } from 'src/enum';
|
||||
|
||||
@Table('audit')
|
||||
@Index({ columns: ['ownerId', 'createdAt'] })
|
||||
export class AuditTable {
|
||||
@PrimaryColumn({ type: 'serial', synchronize: false })
|
||||
id!: Generated<number>;
|
||||
|
||||
@Column()
|
||||
entityType!: EntityType;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
entityId!: string;
|
||||
|
||||
@Column()
|
||||
action!: DatabaseAction;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -563,9 +563,9 @@ describe(AlbumService.name, () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.get(AuthFactory.create(album.owner), album.id, {});
|
||||
await sut.get(AuthFactory.create(album.owner), album.id);
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
@@ -584,9 +584,9 @@ describe(AlbumService.name, () => {
|
||||
]);
|
||||
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
await sut.get(auth, album.id, {});
|
||||
await sut.get(auth, album.id);
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
@@ -605,9 +605,9 @@ describe(AlbumService.name, () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.get(AuthFactory.create(user), album.id, {});
|
||||
await sut.get(AuthFactory.create(user), album.id);
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
new Set([album.id]),
|
||||
@@ -617,7 +617,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.get(auth, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['album-123']));
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AddUsersDto,
|
||||
AlbumInfoDto,
|
||||
AlbumResponseDto,
|
||||
AlbumsAddAssetsDto,
|
||||
AlbumsAddAssetsResponseDto,
|
||||
@@ -10,13 +9,12 @@ import {
|
||||
GetAlbumsDto,
|
||||
mapAlbum,
|
||||
MapAlbumDto,
|
||||
mapAlbumWithAssets,
|
||||
mapAlbumWithoutAssets,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -63,7 +61,7 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
return albums.map((album) => ({
|
||||
...mapAlbumWithoutAssets(album),
|
||||
...mapAlbum(album),
|
||||
sharedLinks: undefined,
|
||||
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
|
||||
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
|
||||
@@ -73,11 +71,10 @@ export class AlbumService extends BaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
|
||||
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
await this.albumRepository.updateThumbnails();
|
||||
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
|
||||
const album = await this.findOrFail(id, { withAssets });
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||
|
||||
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
|
||||
@@ -85,7 +82,7 @@ export class AlbumService extends BaseService {
|
||||
const isShared = hasSharedUsers || hasSharedLink;
|
||||
|
||||
return {
|
||||
...mapAlbum(album, withAssets, auth),
|
||||
...mapAlbum(album),
|
||||
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
|
||||
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
|
||||
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
||||
@@ -94,6 +91,16 @@ export class AlbumService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getMapMarkers(auth: AuthDto, id: string): Promise<MapMarkerResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
|
||||
if (auth.sharedLink && !auth.sharedLink.showExif) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.mapRepository.getAlbumMapMarkers(id);
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
const albumUsers = dto.albumUsers || [];
|
||||
|
||||
@@ -133,7 +140,7 @@ export class AlbumService extends BaseService {
|
||||
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
|
||||
}
|
||||
|
||||
return mapAlbumWithAssets(album);
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
@@ -156,7 +163,7 @@ export class AlbumService extends BaseService {
|
||||
order: dto.order,
|
||||
});
|
||||
|
||||
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
|
||||
return mapAlbum({ ...updatedAlbum, assets: album.assets });
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
@@ -294,7 +301,7 @@ export class AlbumService extends BaseService {
|
||||
await this.eventRepository.emit('AlbumInvite', { id, userId });
|
||||
}
|
||||
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
|
||||
}
|
||||
|
||||
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ApiKey } from 'src/database';
|
||||
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -8,7 +8,7 @@ import { isGranted } from 'src/utils/access';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService extends BaseService {
|
||||
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
async create(auth: AuthDto, dto: ApiKeyCreateDto): Promise<ApiKeyCreateResponseDto> {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
@@ -26,7 +26,7 @@ export class ApiKeyService extends BaseService {
|
||||
return { secret: token, apiKey: this.map(entity) };
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
|
||||
async update(auth: AuthDto, id: string, dto: ApiKeyUpdateDto): Promise<ApiKeyResponseDto> {
|
||||
const exists = await this.apiKeyRepository.getById(auth.user.id, id);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
@@ -54,7 +54,7 @@ export class ApiKeyService extends BaseService {
|
||||
await this.apiKeyRepository.delete(auth.user.id, id);
|
||||
}
|
||||
|
||||
async getMine(auth: AuthDto): Promise<APIKeyResponseDto> {
|
||||
async getMine(auth: AuthDto): Promise<ApiKeyResponseDto> {
|
||||
if (!auth.apiKey) {
|
||||
throw new ForbiddenException('Not authenticated with an API Key');
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export class ApiKeyService extends BaseService {
|
||||
return this.map(key);
|
||||
}
|
||||
|
||||
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
|
||||
async getById(auth: AuthDto, id: string): Promise<ApiKeyResponseDto> {
|
||||
const key = await this.apiKeyRepository.getById(auth.user.id, id);
|
||||
if (!key) {
|
||||
throw new BadRequestException('API Key not found');
|
||||
@@ -75,12 +75,12 @@ export class ApiKeyService extends BaseService {
|
||||
return this.map(key);
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto): Promise<APIKeyResponseDto[]> {
|
||||
async getAll(auth: AuthDto): Promise<ApiKeyResponseDto[]> {
|
||||
const keys = await this.apiKeyRepository.getByUserId(auth.user.id);
|
||||
return keys.map((key) => this.map(key));
|
||||
}
|
||||
|
||||
private map(entity: ApiKey): APIKeyResponseDto {
|
||||
private map(entity: ApiKey): ApiKeyResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
|
||||
@@ -145,8 +145,6 @@ const uploadTests = [
|
||||
];
|
||||
|
||||
const createDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
@@ -156,8 +154,6 @@ const createDto = Object.freeze({
|
||||
const assetEntity = Object.freeze({
|
||||
id: 'id_1',
|
||||
ownerId: 'user_id_1',
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
type: AssetType.Video,
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
@@ -765,17 +761,6 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkExistingAssets', () => {
|
||||
it('should get existing asset ids', async () => {
|
||||
mocks.asset.getByDeviceIds.mockResolvedValue(['42']);
|
||||
await expect(
|
||||
sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }),
|
||||
).resolves.toEqual({ existingIds: ['42'] });
|
||||
|
||||
expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUploadCheck', () => {
|
||||
it('should accept hex and base64 checksums', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
AssetMediaStatus,
|
||||
AssetRejectReason,
|
||||
AssetUploadAction,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaSize,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
@@ -251,18 +249,6 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async checkExistingAssets(
|
||||
auth: AuthDto,
|
||||
checkExistingAssetsDto: CheckExistingAssetsDto,
|
||||
): Promise<CheckExistingAssetsResponseDto> {
|
||||
const existingIds = await this.assetRepository.getByDeviceIds(
|
||||
auth.user.id,
|
||||
checkExistingAssetsDto.deviceId,
|
||||
checkExistingAssetsDto.deviceAssetIds,
|
||||
);
|
||||
return { existingIds };
|
||||
}
|
||||
|
||||
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
|
||||
@@ -340,9 +326,6 @@ export class AssetMediaService extends BaseService {
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
@@ -685,20 +685,6 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAssetsByDeviceId', () => {
|
||||
it('get assets by device id', async () => {
|
||||
const assets = [AssetFactory.create(), AssetFactory.create()];
|
||||
|
||||
mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
|
||||
|
||||
const deviceId = 'device-id';
|
||||
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
||||
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertMetadata', () => {
|
||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -59,10 +59,6 @@ export class AssetService extends BaseService {
|
||||
return mapStats(stats);
|
||||
}
|
||||
|
||||
async getUserAssetsByDeviceId(auth: AuthDto, deviceId: string) {
|
||||
return this.assetRepository.getAllByDeviceId(auth.user.id, deviceId);
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(AuditService.name, () => {
|
||||
let sut: AuditService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(AuditService));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleCleanup', () => {
|
||||
it('should delete old audit entries', async () => {
|
||||
mocks.audit.removeBefore.mockResolvedValue();
|
||||
|
||||
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService extends BaseService {
|
||||
@OnJob({ name: JobName.AuditLogCleanup, queue: QueueName.BackgroundTask })
|
||||
async handleCleanup(): Promise<JobStatus> {
|
||||
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
||||
return JobStatus.Success;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CronRepository } from 'src/repositories/cron.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -72,7 +71,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
AssetRepository,
|
||||
AssetEditRepository,
|
||||
AssetJobRepository,
|
||||
AuditRepository,
|
||||
ConfigRepository,
|
||||
CronRepository,
|
||||
CryptoRepository,
|
||||
@@ -131,7 +129,6 @@ export class BaseService {
|
||||
protected assetRepository: AssetRepository,
|
||||
protected assetEditRepository: AssetEditRepository,
|
||||
protected assetJobRepository: AssetJobRepository,
|
||||
protected auditRepository: AuditRepository,
|
||||
protected configRepository: ConfigRepository,
|
||||
protected cronRepository: CronRepository,
|
||||
protected cryptoRepository: CryptoRepository,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { AuthAdminService } from 'src/services/auth-admin.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
@@ -55,7 +54,6 @@ export const services = [
|
||||
ApiService,
|
||||
AssetMediaService,
|
||||
AssetService,
|
||||
AuditService,
|
||||
AuthService,
|
||||
AuthAdminService,
|
||||
CliService,
|
||||
|
||||
@@ -570,7 +570,6 @@ describe(LibraryService.name, () => {
|
||||
ownerId: library.ownerId,
|
||||
libraryId: library.id,
|
||||
originalPath: '/data/user1/photo.jpg',
|
||||
deviceId: 'Library Import',
|
||||
type: AssetType.Image,
|
||||
originalFileName: 'photo.jpg',
|
||||
isExternal: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Insertable } from 'kysely';
|
||||
import { R_OK } from 'node:constants';
|
||||
import { Stats } from 'node:fs';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import path, { isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
@@ -416,9 +416,6 @@ export class LibraryService extends BaseService {
|
||||
fileCreatedAt: stat.mtime,
|
||||
fileModifiedAt: stat.mtime,
|
||||
localDateTime: stat.mtime,
|
||||
// TODO: device asset id is deprecated, remove it
|
||||
deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''),
|
||||
deviceId: 'Library Import',
|
||||
type: mimeTypes.isVideo(assetPath) ? AssetType.Video : AssetType.Image,
|
||||
originalFileName: parse(assetPath).base,
|
||||
isExternal: true,
|
||||
|
||||
@@ -654,8 +654,6 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
id: motionAsset.id,
|
||||
@@ -708,8 +706,6 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
id: motionAsset.id,
|
||||
@@ -762,8 +758,6 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
id: motionAsset.id,
|
||||
|
||||
@@ -681,8 +681,6 @@ export class MetadataService extends BaseService {
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
});
|
||||
|
||||
isNewMotionAsset = true;
|
||||
|
||||
@@ -42,7 +42,6 @@ describe(QueueService.name, () => {
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
{ name: JobName.MemoryGenerate },
|
||||
{ name: JobName.UserSyncUsage },
|
||||
{ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } },
|
||||
|
||||
@@ -270,7 +270,6 @@ export class QueueService extends BaseService {
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const untilDate = new Date(2024);
|
||||
const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true };
|
||||
|
||||
describe(SyncService.name, () => {
|
||||
let sut: SyncService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(SyncService));
|
||||
});
|
||||
|
||||
it('should exist', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAllAssetsForUserFullSync', () => {
|
||||
it('should return a list of all assets owned by the user', async () => {
|
||||
const [asset1, asset2] = [
|
||||
AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(),
|
||||
AssetFactory.from().owner(authStub.user1.user).build(),
|
||||
];
|
||||
mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]);
|
||||
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
|
||||
mapAsset(getForAsset(asset1), mapAssetOpts),
|
||||
mapAsset(getForAsset(asset2), mapAssetOpts),
|
||||
]);
|
||||
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({
|
||||
ownerId: authStub.user1.user.id,
|
||||
updatedUntil: untilDate,
|
||||
limit: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChangesForDeltaSync', () => {
|
||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||
const partner = PartnerFactory.create();
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }),
|
||||
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
|
||||
|
||||
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return a response requiring a full sync when last sync was too long ago', async () => {
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }),
|
||||
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
|
||||
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return a response requiring a full sync when there are too many changes', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue(
|
||||
Array.from<ReturnType<typeof getForAsset>>({ length: 10_000 }).fill(getForAsset(asset)),
|
||||
);
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
|
||||
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
|
||||
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return a response with changes and deletions', async () => {
|
||||
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
|
||||
const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]);
|
||||
mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]);
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
|
||||
).resolves.toEqual({
|
||||
needsFullSync: false,
|
||||
upserted: [mapAsset(getForAsset(asset), mapAssetOpts)],
|
||||
deleted: [deletedAsset.id],
|
||||
});
|
||||
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,9 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
|
||||
import { Insertable } from 'kysely';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Writable } from 'node:stream';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetDeltaSyncDto,
|
||||
AssetDeltaSyncResponseDto,
|
||||
AssetFullSyncDto,
|
||||
SyncAckDeleteDto,
|
||||
SyncAckSetDto,
|
||||
syncAssetFaceV2ToV1,
|
||||
@@ -17,23 +12,12 @@ import {
|
||||
SyncItem,
|
||||
SyncStreamDto,
|
||||
} from 'src/dtos/sync.dto';
|
||||
import {
|
||||
AssetVisibility,
|
||||
DatabaseAction,
|
||||
EntityType,
|
||||
JobName,
|
||||
Permission,
|
||||
QueueName,
|
||||
SyncEntityType,
|
||||
SyncRequestType,
|
||||
} from 'src/enum';
|
||||
import { JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { SyncQueryOptions } from 'src/repositories/sync.repository';
|
||||
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { SyncAck } from 'src/types';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { setIsEqual } from 'src/utils/set';
|
||||
import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync';
|
||||
|
||||
type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>;
|
||||
@@ -66,7 +50,6 @@ const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityTy
|
||||
send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] });
|
||||
};
|
||||
|
||||
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
||||
export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.AuthUsersV1,
|
||||
SyncRequestType.UsersV1,
|
||||
@@ -887,68 +870,4 @@ export class SyncService extends BaseService {
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
|
||||
// mobile implementation is faster if this is a single id
|
||||
const userId = dto.userId || auth.user.id;
|
||||
await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: [userId] });
|
||||
const assets = await this.assetRepository.getAllForUserFullSync({
|
||||
ownerId: userId,
|
||||
updatedUntil: dto.updatedUntil,
|
||||
lastId: dto.lastId,
|
||||
limit: dto.limit,
|
||||
});
|
||||
return assets.map((a) => mapAsset(a, { auth, stripMetadata: false, withStack: true }));
|
||||
}
|
||||
|
||||
async getDeltaSync(auth: AuthDto, dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
|
||||
// app has not synced in the last 100 days
|
||||
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.updatedAfter));
|
||||
if (duration > AUDIT_LOG_MAX_DURATION) {
|
||||
return FULL_SYNC;
|
||||
}
|
||||
|
||||
// app does not have the correct partners synced
|
||||
const partnerIds = await getMyPartnerIds({ userId: auth.user.id, repository: this.partnerRepository });
|
||||
const userIds = [auth.user.id, ...partnerIds];
|
||||
if (!setIsEqual(new Set(userIds), new Set(dto.userIds))) {
|
||||
return FULL_SYNC;
|
||||
}
|
||||
|
||||
await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: dto.userIds });
|
||||
|
||||
const limit = 10_000;
|
||||
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });
|
||||
|
||||
// too many changes, need to do a full sync
|
||||
if (upserted.length === limit) {
|
||||
return FULL_SYNC;
|
||||
}
|
||||
|
||||
const deleted = await this.auditRepository.getAfter(dto.updatedAfter, {
|
||||
userIds,
|
||||
entityType: EntityType.Asset,
|
||||
action: DatabaseAction.Delete,
|
||||
});
|
||||
|
||||
const result = {
|
||||
needsFullSync: false,
|
||||
upserted: upserted
|
||||
// do not return archived assets for partner users
|
||||
.filter(
|
||||
(a) =>
|
||||
a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.Timeline),
|
||||
)
|
||||
.map((a) =>
|
||||
mapAsset(a, {
|
||||
auth,
|
||||
stripMetadata: false,
|
||||
// ignore stacks for non partner users
|
||||
withStack: a.ownerId === auth.user.id,
|
||||
}),
|
||||
),
|
||||
deleted,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,6 @@ export type JobItem =
|
||||
| { name: JobName.FileDelete; data: IDeleteFilesJob }
|
||||
|
||||
// Cleanup
|
||||
| { name: JobName.AuditLogCleanup; data?: IBaseJob }
|
||||
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
||||
|
||||
// Tags
|
||||
|
||||
@@ -351,8 +351,6 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.where('asset_exif.rating', options.rating === null ? 'is' : '=', options.rating!),
|
||||
)
|
||||
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
|
||||
.$if(!!options.deviceAssetId, (qb) => qb.where('asset.deviceAssetId', '=', options.deviceAssetId!))
|
||||
.$if(!!options.deviceId, (qb) => qb.where('asset.deviceId', '=', options.deviceId!))
|
||||
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
||||
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||
|
||||
@@ -21,8 +21,6 @@ const createAsset = (
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceAssetId: 'device-asset-1',
|
||||
deviceId: 'device-1',
|
||||
ownerId: 'owner-1',
|
||||
originalPath: '/path/to/asset',
|
||||
originalFileName: 'asset.jpg',
|
||||
|
||||
Reference in New Issue
Block a user