immich/server/src/repositories/stack.repository.ts
xCJPECKOVERx 1a70896113
feat(web): Remove from Stack (#19703)
* - add component
- update server's StackCreateDto for merge parameter
- Update stackRepo to only merge stacks when merge=true (default)
- update web action handlers to show stack changes

* - make open-api

* lint & format

* - Add proper icon to 'remove from stack'
- change web unstack icon to image-off-outline

* - cleanup

* - format & lint

* - make open-api: StackCreateDto merge optional

* initial addition of new endpoint

* remove stack endpoint

* - fix up remove stack endpoint
- open-api

* - Undo stackCreate merge parameter

* - open-api typescript

* open-api dart

* Tests:
- add tests
- update assetStub.imageFrom2015 to have required stack attributes to include it with tests

* update event name

* Fix event name in test

* remove asset_update check

* - merge stack.removeAsset params into one object
- refactor asset existence check (no need for asset fetch)
- fix tests

* Don't return updated stack

* Create specialized stack id & primary asset fetch for asset removal checks

* Correct new permission names

* make sql

* - fix open-api

* - cleanup
2025-07-22 22:17:06 -04:00

166 lines
4.8 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { StackTable } from 'src/schema/tables/stack.table';
import { asUuid, withDefaultVisibility } from 'src/utils/database';
export interface StackSearch {
ownerId: string;
primaryAssetId?: string;
}
const withAssets = (eb: ExpressionBuilder<DB, 'stack'>, withTags = false) => {
return jsonArrayFrom(
eb
.selectFrom('asset')
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('asset_exif')
.select(columns.exif)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo'),
(join) => join.onTrue(),
)
.$if(withTags, (eb) =>
eb.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tag')
.select(columns.tag)
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
.whereRef('tag_asset.assetsId', '=', 'asset.id'),
).as('tags'),
),
)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.where('asset.deletedAt', 'is', null)
.whereRef('asset.stackId', '=', 'stack.id')
.$call(withDefaultVisibility),
).as('assets');
};
@Injectable()
export class StackRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID }] })
search(query: StackSearch) {
return this.db
.selectFrom('stack')
.selectAll('stack')
.select(withAssets)
.where('stack.ownerId', '=', query.ownerId)
.$if(!!query.primaryAssetId, (eb) => eb.where('stack.primaryAssetId', '=', query.primaryAssetId!))
.execute();
}
async create(entity: Omit<Insertable<StackTable>, 'primaryAssetId'>, assetIds: string[]) {
return this.db.transaction().execute(async (tx) => {
const stacks = await tx
.selectFrom('stack')
.where('stack.ownerId', '=', entity.ownerId)
.where('stack.primaryAssetId', 'in', assetIds)
.select('stack.id')
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset')
.select('asset.id')
.whereRef('asset.stackId', '=', 'stack.id')
.where('asset.deletedAt', 'is', null),
).as('assets'),
)
.execute();
const uniqueIds = new Set<string>(assetIds);
// children
for (const stack of stacks) {
if (stack.assets && stack.assets.length > 0) {
for (const asset of stack.assets) {
uniqueIds.add(asset.id);
}
}
}
if (stacks.length > 0) {
await tx
.deleteFrom('stack')
.where(
'id',
'in',
stacks.map((stack) => stack.id),
)
.execute();
}
const newRecord = await tx
.insertInto('stack')
.values({ ...entity, primaryAssetId: assetIds[0] })
.returning('id')
.executeTakeFirstOrThrow();
await tx
.updateTable('asset')
.set({
stackId: newRecord.id,
updatedAt: new Date(),
})
.where('id', 'in', [...uniqueIds])
.execute();
return tx
.selectFrom('stack')
.selectAll('stack')
.select(withAssets)
.where('id', '=', newRecord.id)
.executeTakeFirstOrThrow();
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> {
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
}
async deleteAll(ids: string[]): Promise<void> {
await this.db.deleteFrom('stack').where('id', 'in', ids).execute();
}
update(id: string, entity: Updateable<StackTable>) {
return this.db
.updateTable('stack')
.set(entity)
.where('id', '=', asUuid(id))
.returningAll('stack')
.returning((eb) => withAssets(eb, true))
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string) {
return this.db
.selectFrom('stack')
.selectAll()
.select((eb) => withAssets(eb, true))
.where('id', '=', asUuid(id))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getForAssetRemoval(assetId: string) {
return this.db
.selectFrom('asset')
.leftJoin('stack', 'stack.id', 'asset.stackId')
.select(['stackId as id', 'stack.primaryAssetId'])
.where('asset.id', '=', assetId)
.executeTakeFirst();
}
}