mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server): stacks (#11453)
* refactor: stacks * mobile: get it built * chore: feedback * fix: sync and duplicates * mobile: remove old stack reference * chore: add primary asset id * revert change to asset entity * mobile: refactor mobile api * mobile: sync stack info after creating stack * mobile: update timeline after deleting stack * server: update asset updatedAt when stack is deleted * mobile: simplify action * mobile: rename to match dto property * fix: web test --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
ca52cbace1
commit
8338657eaa
63 changed files with 2321 additions and 1152 deletions
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
||||
import { UpdateStackParentDto } from 'src/dtos/stack.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { Route } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
|
|
@ -72,13 +71,6 @@ export class AssetController {
|
|||
return this.service.deleteAll(auth, dto);
|
||||
}
|
||||
|
||||
@Put('stack/parent')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated()
|
||||
updateStackParent(@Auth() auth: AuthDto, @Body() dto: UpdateStackParentDto): Promise<void> {
|
||||
return this.service.updateStackParent(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ sharedLink: true })
|
||||
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { ServerInfoController } from 'src/controllers/server-info.controller';
|
|||
import { ServerController } from 'src/controllers/server.controller';
|
||||
import { SessionController } from 'src/controllers/session.controller';
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { StackController } from 'src/controllers/stack.controller';
|
||||
import { SyncController } from 'src/controllers/sync.controller';
|
||||
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
||||
import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
|
||||
|
|
@ -58,6 +59,7 @@ export const controllers = [
|
|||
ServerInfoController,
|
||||
SessionController,
|
||||
SharedLinkController,
|
||||
StackController,
|
||||
SyncController,
|
||||
SystemConfigController,
|
||||
SystemMetadataController,
|
||||
|
|
|
|||
57
server/src/controllers/stack.controller.ts
Normal file
57
server/src/controllers/stack.controller.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from 'src/dtos/stack.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Stacks')
|
||||
@Controller('stacks')
|
||||
export class StackController {
|
||||
constructor(private service: StackService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.STACK_READ })
|
||||
searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise<StackResponseDto[]> {
|
||||
return this.service.search(auth, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.STACK_CREATE })
|
||||
createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise<StackResponseDto> {
|
||||
return this.service.create(auth, dto);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Authenticated({ permission: Permission.STACK_DELETE })
|
||||
deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
|
||||
return this.service.deleteAll(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.STACK_READ })
|
||||
getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<StackResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.STACK_UPDATE })
|
||||
updateStack(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: StackUpdateDto,
|
||||
): Promise<StackResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Authenticated({ permission: Permission.STACK_DELETE })
|
||||
deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -292,6 +292,18 @@ export class AccessCore {
|
|||
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.STACK_READ: {
|
||||
return this.repository.stack.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.STACK_UPDATE: {
|
||||
return this.repository.stack.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.STACK_DELETE: {
|
||||
return this.repository.stack.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
default: {
|
||||
return new Set();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,13 +52,19 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
checksum!: string;
|
||||
stackParentId?: string | null;
|
||||
stack?: AssetResponseDto[];
|
||||
@ApiProperty({ type: 'integer' })
|
||||
stackCount!: number | null;
|
||||
stack?: AssetStackResponseDto | null;
|
||||
duplicateId?: string | null;
|
||||
}
|
||||
|
||||
export class AssetStackResponseDto {
|
||||
id!: string;
|
||||
|
||||
primaryAssetId!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export type AssetMapOptions = {
|
||||
stripMetadata?: boolean;
|
||||
withStack?: boolean;
|
||||
|
|
@ -83,6 +89,18 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
|
|||
return result;
|
||||
};
|
||||
|
||||
const mapStack = (entity: AssetEntity) => {
|
||||
if (!entity.stack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entity.stack.id,
|
||||
primaryAssetId: entity.stack.primaryAssetId,
|
||||
assetCount: entity.stack.assetCount ?? entity.stack.assets.length,
|
||||
};
|
||||
};
|
||||
|
||||
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
|
|
@ -129,13 +147,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
|||
people: peopleWithFaces(entity.faces),
|
||||
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
||||
checksum: entity.checksum.toString('base64'),
|
||||
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
|
||||
stack: withStack
|
||||
? entity.stack?.assets
|
||||
?.filter((a) => a.id !== entity.stack?.primaryAssetId)
|
||||
?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth }))
|
||||
: undefined,
|
||||
stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
hasMetadata: true,
|
||||
duplicateId: entity.duplicateId,
|
||||
|
|
|
|||
|
|
@ -60,12 +60,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
|||
@ValidateUUID({ each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
stackParentId?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
removeParent?: boolean;
|
||||
|
||||
@Optional()
|
||||
duplicateId?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import { ArrayMinSize } from 'class-validator';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { ValidateUUID } from 'src/validation';
|
||||
|
||||
export class UpdateStackParentDto {
|
||||
@ValidateUUID()
|
||||
oldParentId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
newParentId!: string;
|
||||
export class StackCreateDto {
|
||||
/** first asset becomes the primary */
|
||||
@ValidateUUID({ each: true })
|
||||
@ArrayMinSize(2)
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class StackSearchDto {
|
||||
primaryAssetId?: string;
|
||||
}
|
||||
|
||||
export class StackUpdateDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
primaryAssetId?: string;
|
||||
}
|
||||
|
||||
export class StackResponseDto {
|
||||
id!: string;
|
||||
primaryAssetId!: string;
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export const mapStack = (stack: StackEntity, { auth }: { auth?: AuthDto }) => {
|
||||
const primary = stack.assets.filter((asset) => asset.id === stack.primaryAssetId);
|
||||
const others = stack.assets.filter((asset) => asset.id !== stack.primaryAssetId);
|
||||
|
||||
return {
|
||||
id: stack.id,
|
||||
primaryAssetId: stack.primaryAssetId,
|
||||
assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ export enum Permission {
|
|||
SHARED_LINK_UPDATE = 'sharedLink.update',
|
||||
SHARED_LINK_DELETE = 'sharedLink.delete',
|
||||
|
||||
STACK_CREATE = 'stack.create',
|
||||
STACK_READ = 'stack.read',
|
||||
STACK_UPDATE = 'stack.update',
|
||||
STACK_DELETE = 'stack.delete',
|
||||
|
||||
SYSTEM_CONFIG_READ = 'systemConfig.read',
|
||||
SYSTEM_CONFIG_UPDATE = 'systemConfig.update',
|
||||
|
||||
|
|
|
|||
|
|
@ -42,4 +42,8 @@ export interface IAccessRepository {
|
|||
partner: {
|
||||
checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
stack: {
|
||||
checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@ import { StackEntity } from 'src/entities/stack.entity';
|
|||
|
||||
export const IStackRepository = 'IStackRepository';
|
||||
|
||||
export interface StackSearch {
|
||||
ownerId: string;
|
||||
primaryAssetId?: string;
|
||||
}
|
||||
|
||||
export interface IStackRepository {
|
||||
create(stack: Partial<StackEntity> & { ownerId: string }): Promise<StackEntity>;
|
||||
search(query: StackSearch): Promise<StackEntity[]>;
|
||||
create(stack: { ownerId: string; assetIds: string[] }): Promise<StackEntity>;
|
||||
update(stack: Pick<StackEntity, 'id'> & Partial<StackEntity>): Promise<StackEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
deleteAll(ids: string[]): Promise<void>;
|
||||
getById(id: string): Promise<StackEntity | null>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,17 @@ WHERE
|
|||
"partner"."sharedById" IN ($1)
|
||||
AND "partner"."sharedWithId" = $2
|
||||
|
||||
-- AccessRepository.stack.checkOwnerAccess
|
||||
SELECT
|
||||
"StackEntity"."id" AS "StackEntity_id"
|
||||
FROM
|
||||
"asset_stack" "StackEntity"
|
||||
WHERE
|
||||
(
|
||||
("StackEntity"."id" IN ($1))
|
||||
AND ("StackEntity"."ownerId" = $2)
|
||||
)
|
||||
|
||||
-- AccessRepository.timeline.checkPartnerAccess
|
||||
SELECT
|
||||
"partner"."sharedById" AS "partner_sharedById",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { PartnerEntity } from 'src/entities/partner.entity';
|
|||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
|
@ -20,10 +21,11 @@ type IActivityAccess = IAccessRepository['activity'];
|
|||
type IAlbumAccess = IAccessRepository['album'];
|
||||
type IAssetAccess = IAccessRepository['asset'];
|
||||
type IAuthDeviceAccess = IAccessRepository['authDevice'];
|
||||
type ITimelineAccess = IAccessRepository['timeline'];
|
||||
type IMemoryAccess = IAccessRepository['memory'];
|
||||
type IPersonAccess = IAccessRepository['person'];
|
||||
type IPartnerAccess = IAccessRepository['partner'];
|
||||
type IStackAccess = IAccessRepository['stack'];
|
||||
type ITimelineAccess = IAccessRepository['timeline'];
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
|
|
@ -313,6 +315,28 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
|
|||
}
|
||||
}
|
||||
|
||||
class StackAccess implements IStackAccess {
|
||||
constructor(private stackRepository: Repository<StackEntity>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>> {
|
||||
if (stackIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.stackRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...stackIds]),
|
||||
ownerId: userId,
|
||||
},
|
||||
})
|
||||
.then((stacks) => new Set(stacks.map((stack) => stack.id)));
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineAccess implements ITimelineAccess {
|
||||
constructor(private partnerRepository: Repository<PartnerEntity>) {}
|
||||
|
||||
|
|
@ -428,6 +452,7 @@ export class AccessRepository implements IAccessRepository {
|
|||
memory: IMemoryAccess;
|
||||
person: IPersonAccess;
|
||||
partner: IPartnerAccess;
|
||||
stack: IStackAccess;
|
||||
timeline: ITimelineAccess;
|
||||
|
||||
constructor(
|
||||
|
|
@ -441,6 +466,7 @@ export class AccessRepository implements IAccessRepository {
|
|||
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
|
||||
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
|
||||
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
|
||||
@InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
|
||||
) {
|
||||
this.activity = new ActivityAccess(activityRepository, albumRepository);
|
||||
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
|
||||
|
|
@ -449,6 +475,7 @@ export class AccessRepository implements IAccessRepository {
|
|||
this.memory = new MemoryAccess(memoryRepository);
|
||||
this.person = new PersonAccess(assetFaceRepository, personRepository);
|
||||
this.partner = new PartnerAccess(partnerRepository);
|
||||
this.stack = new StackAccess(stackRepository);
|
||||
this.timeline = new TimelineAccess(partnerRepository);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,120 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class StackRepository implements IStackRepository {
|
||||
constructor(@InjectRepository(StackEntity) private repository: Repository<StackEntity>) {}
|
||||
constructor(
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@InjectRepository(StackEntity) private repository: Repository<StackEntity>,
|
||||
) {}
|
||||
|
||||
create(entity: Partial<StackEntity>) {
|
||||
return this.save(entity);
|
||||
search(query: StackSearch): Promise<StackEntity[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
ownerId: query.ownerId,
|
||||
primaryAssetId: query.primaryAssetId,
|
||||
},
|
||||
relations: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(entity: { ownerId: string; assetIds: string[] }): Promise<StackEntity> {
|
||||
return this.dataSource.manager.transaction(async (manager) => {
|
||||
const stackRepository = manager.getRepository(StackEntity);
|
||||
|
||||
const stacks = await stackRepository.find({
|
||||
where: {
|
||||
ownerId: entity.ownerId,
|
||||
primaryAssetId: In(entity.assetIds),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
assets: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const assetIds = new Set<string>(entity.assetIds);
|
||||
|
||||
// children
|
||||
for (const stack of stacks) {
|
||||
for (const asset of stack.assets) {
|
||||
assetIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (stacks.length > 0) {
|
||||
await stackRepository.delete({ id: In(stacks.map((stack) => stack.id)) });
|
||||
}
|
||||
|
||||
const { id } = await stackRepository.save({
|
||||
ownerId: entity.ownerId,
|
||||
primaryAssetId: entity.assetIds[0],
|
||||
assets: [...assetIds].map((id) => ({ id }) as AssetEntity),
|
||||
});
|
||||
|
||||
return stackRepository.findOneOrFail({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const stack = await this.getById(id);
|
||||
if (!stack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetIds = stack.assets.map(({ id }) => id);
|
||||
|
||||
await this.repository.delete(id);
|
||||
|
||||
// Update assets updatedAt
|
||||
await this.dataSource.manager.update(AssetEntity, assetIds, {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAll(ids: string[]): Promise<void> {
|
||||
const assetIds = [];
|
||||
for (const id of ids) {
|
||||
const stack = await this.getById(id);
|
||||
if (!stack) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assetIds.push(...stack.assets.map(({ id }) => id));
|
||||
}
|
||||
|
||||
await this.repository.delete(ids);
|
||||
|
||||
// Update assets updatedAt
|
||||
await this.dataSource.manager.update(AssetEntity, assetIds, {
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
update(entity: Partial<StackEntity>) {
|
||||
|
|
@ -28,8 +127,14 @@ export class StackRepository implements IStackRepository {
|
|||
id,
|
||||
},
|
||||
relations: {
|
||||
primaryAsset: true,
|
||||
assets: true,
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
assets: {
|
||||
fileCreatedAt: 'ASC',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -41,8 +146,14 @@ export class StackRepository implements IStackRepository {
|
|||
id,
|
||||
},
|
||||
relations: {
|
||||
primaryAsset: true,
|
||||
assets: true,
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
assets: {
|
||||
fileCreatedAt: 'ASC',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
|||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
|
|
@ -12,7 +12,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
|
|||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { assetStub, stackStub } from 'test/fixtures/asset.stub';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { faceStub } from 'test/fixtures/face.stub';
|
||||
import { partnerStub } from 'test/fixtures/partner.stub';
|
||||
|
|
@ -253,134 +253,6 @@ describe(AssetService.name, () => {
|
|||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||
});
|
||||
|
||||
/// Stack related
|
||||
|
||||
it('should require asset update access for parent', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await expect(
|
||||
sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
stackParentId: 'parent',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update parent asset updatedAt when children are added', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent']));
|
||||
mockGetById([{ ...assetStub.image, id: 'parent' }]);
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: [],
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { updatedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it('should update parent asset when children are removed', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1']));
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{
|
||||
id: 'child-1',
|
||||
stackId: 'stack-1',
|
||||
stack: stackStub('stack-1', [{ id: 'parent' } as AssetEntity, { id: 'child-1' } as AssetEntity]),
|
||||
} as AssetEntity,
|
||||
]);
|
||||
stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity]));
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['child-1'],
|
||||
removeParent: true,
|
||||
});
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['child-1']), { stack: null });
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), {
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(stackMock.delete).toHaveBeenCalledWith('stack-1');
|
||||
});
|
||||
|
||||
it('update parentId for new children', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
const stack = stackStub('stack-1', [
|
||||
{ id: 'parent' } as AssetEntity,
|
||||
{ id: 'child-1' } as AssetEntity,
|
||||
{ id: 'child-2' } as AssetEntity,
|
||||
]);
|
||||
assetMock.getById.mockResolvedValue({
|
||||
id: 'child-1',
|
||||
stack,
|
||||
} as AssetEntity);
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
stackParentId: 'parent',
|
||||
ids: ['child-1', 'child-2'],
|
||||
});
|
||||
|
||||
expect(stackMock.update).toHaveBeenCalledWith({
|
||||
...stackStub('stack-1', [
|
||||
{ id: 'child-1' } as AssetEntity,
|
||||
{ id: 'child-2' } as AssetEntity,
|
||||
{ id: 'parent' } as AssetEntity,
|
||||
]),
|
||||
primaryAsset: undefined,
|
||||
});
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2', 'parent'], { updatedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it('remove stack for removed children', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2']));
|
||||
await sut.updateAll(authStub.user1, {
|
||||
removeParent: true,
|
||||
ids: ['child-1', 'child-2'],
|
||||
});
|
||||
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stack: null });
|
||||
});
|
||||
|
||||
it('merge stacks if new child has children', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
assetMock.getById.mockResolvedValue({ ...assetStub.image, id: 'parent' });
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{
|
||||
id: 'child-1',
|
||||
stackId: 'stack-1',
|
||||
stack: stackStub('stack-1', [{ id: 'child-1' } as AssetEntity, { id: 'child-2' } as AssetEntity]),
|
||||
} as AssetEntity,
|
||||
]);
|
||||
stackMock.getById.mockResolvedValue(stackStub('stack-1', [{ id: 'parent' } as AssetEntity]));
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['child-1'],
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
|
||||
expect(stackMock.delete).toHaveBeenCalledWith('stack-1');
|
||||
expect(stackMock.create).toHaveBeenCalledWith({
|
||||
assets: [{ id: 'child-1' }, { id: 'parent' }, { id: 'child-1' }, { id: 'child-2' }],
|
||||
ownerId: 'user-id',
|
||||
primaryAssetId: 'parent',
|
||||
});
|
||||
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'parent', 'child-1', 'child-2'], {
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it('should send ws asset update event', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [
|
||||
'asset-1',
|
||||
'parent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
|
|
@ -530,53 +402,17 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('updateStackParent', () => {
|
||||
it('should require asset update access for new parent', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old']));
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
newParentId: 'new',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
describe('getUserAssetsByDeviceId', () => {
|
||||
it('get assets by device id', async () => {
|
||||
const assets = [assetStub.image, assetStub.image1];
|
||||
|
||||
assetMock.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));
|
||||
});
|
||||
|
||||
it('should require asset read access for old parent', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new']));
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
newParentId: 'new',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('make old parent the child of new parent', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new']));
|
||||
assetMock.getById.mockResolvedValue({ ...assetStub.image, stackId: 'stack-1' });
|
||||
|
||||
await sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: assetStub.image.id,
|
||||
newParentId: 'new',
|
||||
});
|
||||
|
||||
expect(stackMock.update).toBeCalledWith({ id: 'stack-1', primaryAssetId: 'new' });
|
||||
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id, 'new', assetStub.image.id], {
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('get assets by device id', async () => {
|
||||
const assets = [assetStub.image, assetStub.image1];
|
||||
|
||||
assetMock.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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
||||
import { UpdateStackParentDto } from 'src/dtos/stack.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
|
|
@ -179,68 +178,14 @@ export class AssetService {
|
|||
}
|
||||
|
||||
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||
const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
|
||||
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, ids);
|
||||
|
||||
// TODO: refactor this logic into separate API calls POST /stack, PUT /stack, etc.
|
||||
const stackIdsToCheckForDelete: string[] = [];
|
||||
if (removeParent) {
|
||||
(options as Partial<AssetEntity>).stack = null;
|
||||
const assets = await this.assetRepository.getByIds(ids, { stack: true });
|
||||
stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!)));
|
||||
// This updates the updatedAt column of the parents to indicate that one of its children is removed
|
||||
// All the unique parent's -> parent is set to null
|
||||
await this.assetRepository.updateAll(
|
||||
assets.filter((a) => !!a.stack?.primaryAssetId).map((a) => a.stack!.primaryAssetId!),
|
||||
{ updatedAt: new Date() },
|
||||
);
|
||||
} else if (options.stackParentId) {
|
||||
//Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId);
|
||||
const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } });
|
||||
if (!primaryAsset) {
|
||||
throw new BadRequestException('Asset not found for given stackParentId');
|
||||
}
|
||||
let stack = primaryAsset.stack;
|
||||
|
||||
ids.push(options.stackParentId);
|
||||
const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } });
|
||||
stackIdsToCheckForDelete.push(
|
||||
...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)),
|
||||
);
|
||||
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.assets.length > 0);
|
||||
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.assets.map((gChild) => gChild.id)));
|
||||
|
||||
if (stack) {
|
||||
await this.stackRepository.update({
|
||||
id: stack.id,
|
||||
primaryAssetId: primaryAsset.id,
|
||||
assets: ids.map((id) => ({ id }) as AssetEntity),
|
||||
});
|
||||
} else {
|
||||
stack = await this.stackRepository.create({
|
||||
primaryAssetId: primaryAsset.id,
|
||||
ownerId: primaryAsset.ownerId,
|
||||
assets: ids.map((id) => ({ id }) as AssetEntity),
|
||||
});
|
||||
}
|
||||
|
||||
// Merge stacks
|
||||
options.stackParentId = undefined;
|
||||
(options as Partial<AssetEntity>).updatedAt = new Date();
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
|
||||
}
|
||||
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
const stackIdsToDelete = await Promise.all(stackIdsToCheckForDelete.map((id) => this.stackRepository.getById(id)));
|
||||
const stacksToDelete = stackIdsToDelete
|
||||
.flatMap((stack) => (stack ? [stack] : []))
|
||||
.filter((stack) => stack.assets.length < 2);
|
||||
await Promise.all(stacksToDelete.map((as) => this.stackRepository.delete(as.id)));
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids);
|
||||
}
|
||||
|
||||
async handleAssetDeletionCheck(): Promise<JobStatus> {
|
||||
|
|
@ -343,41 +288,6 @@ export class AssetService {
|
|||
}
|
||||
}
|
||||
|
||||
async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise<void> {
|
||||
const { oldParentId, newParentId } = dto;
|
||||
await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId);
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, newParentId);
|
||||
|
||||
const childIds: string[] = [];
|
||||
const oldParent = await this.assetRepository.getById(oldParentId, {
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
library: true,
|
||||
stack: {
|
||||
assets: true,
|
||||
},
|
||||
});
|
||||
if (!oldParent?.stackId) {
|
||||
throw new Error('Asset not found or not in a stack');
|
||||
}
|
||||
if (oldParent != null) {
|
||||
// Get all children of old parent
|
||||
childIds.push(oldParent.id, ...(oldParent.stack?.assets.map((a) => a.id) ?? []));
|
||||
}
|
||||
await this.stackRepository.update({
|
||||
id: oldParent.stackId,
|
||||
primaryAssetId: newParentId,
|
||||
});
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [
|
||||
...childIds,
|
||||
newParentId,
|
||||
oldParentId,
|
||||
]);
|
||||
await this.assetRepository.updateAll([oldParentId, newParentId, ...childIds], { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export class DuplicateService {
|
|||
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
|
||||
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
|
||||
|
||||
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth })));
|
||||
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true })));
|
||||
}
|
||||
|
||||
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { ServerService } from 'src/services/server.service';
|
|||
import { SessionService } from 'src/services/session.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
|
|
@ -65,6 +66,7 @@ export const services = [
|
|||
SessionService,
|
||||
SharedLinkService,
|
||||
SmartInfoService,
|
||||
StackService,
|
||||
StorageService,
|
||||
StorageTemplateService,
|
||||
SyncService,
|
||||
|
|
|
|||
84
server/src/services/stack.service.ts
Normal file
84
server/src/services/stack.service.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AccessCore } from 'src/cores/access.core';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
|
||||
@Injectable()
|
||||
export class StackService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IStackRepository) private stackRepository: IStackRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: StackSearchDto): Promise<StackResponseDto[]> {
|
||||
const stacks = await this.stackRepository.search({
|
||||
ownerId: auth.user.id,
|
||||
primaryAssetId: dto.primaryAssetId,
|
||||
});
|
||||
|
||||
return stacks.map((stack) => mapStack(stack, { auth }));
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds);
|
||||
|
||||
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
||||
|
||||
return mapStack(stack, { auth });
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<StackResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.STACK_READ, id);
|
||||
const stack = await this.findOrFail(id);
|
||||
return mapStack(stack, { auth });
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.STACK_UPDATE, id);
|
||||
const stack = await this.findOrFail(id);
|
||||
if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) {
|
||||
throw new BadRequestException('Primary asset must be in the stack');
|
||||
}
|
||||
|
||||
const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId });
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
||||
|
||||
return mapStack(updatedStack, { auth });
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(auth, Permission.STACK_DELETE, id);
|
||||
await this.stackRepository.delete(id);
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
||||
}
|
||||
|
||||
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
||||
await this.access.requirePermission(auth, Permission.STACK_DELETE, dto.ids);
|
||||
await this.stackRepository.deleteAll(dto.ids);
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []);
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const stack = await this.stackRepository.getById(id);
|
||||
if (!stack) {
|
||||
throw new Error('Asset stack not found');
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue