fix: people/face query

This commit is contained in:
Jason Rasmussen 2026-08-13 13:16:58 -04:00
parent 7042c63ed1
commit 42c791106a
No known key found for this signature in database
GPG key ID: 2EF24B77EAFA4A41
7 changed files with 71 additions and 70 deletions

View file

@ -215,6 +215,6 @@ export function mapFaces(
): AssetFaceResponseDto { ): AssetFaceResponseDto {
return { return {
...mapFacesWithoutPerson(face, edits, assetDimensions), ...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, person: face.person ? mapPerson(face.person) : null,
}; };
} }

View file

@ -131,16 +131,15 @@ select
"person" "person"
where where
"person"."personGroupId" = "asset_face"."personGroupId" "person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId" and "person"."ownerId" = $1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where where
"asset_face"."assetId" = $1 "asset_face"."assetId" = $2
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2 and "asset_face"."isVisible" = $3
order by order by
"asset_face"."boundingBoxX1" asc "asset_face"."boundingBoxX1" asc
@ -158,14 +157,13 @@ select
"person" "person"
where where
"person"."personGroupId" = "asset_face"."personGroupId" "person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId" and "person"."ownerId" = $1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where where
"asset_face"."id" = $1 "asset_face"."id" = $2
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob -- PersonRepository.getFaceForFacialRecognitionJob
@ -521,15 +519,14 @@ select
"person" "person"
where where
"person"."personGroupId" = "asset_face"."personGroupId" "person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId" and "person"."ownerId" = $1
) as obj ) as obj
) as "person" ) as "person"
from from
"asset_face" "asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
where where
"asset_face"."assetId" in ($1) "asset_face"."assetId" in ($2)
and "asset_face"."personGroupId" in ($2) and "asset_face"."personGroupId" in ($3)
and "asset_face"."deletedAt" is null and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace -- PersonRepository.getRandomFace

View file

@ -63,19 +63,27 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[]; export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
export type GetFacesOptions = WithPersonOptions & { isVisible?: boolean };
/** a person is identified by its owner and the group it belongs to */ /** a person is identified by its owner and the group it belongs to */
export type PersonId = { ownerId: string; personGroupId: string }; export type PersonId = { ownerId: string; personGroupId: string };
export type ReassignCluster = { userId: string; newClusterId: string }; export type ReassignCluster = { userId: string; newClusterId: string };
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face' | 'asset'>) => { export type WithPersonOptions = {
return jsonObjectFrom( /** whose version of the person to select */
eb viewingUserId: string;
.selectFrom('person') };
.selectAll('person')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId') const withPerson = ({ viewingUserId }: WithPersonOptions) => {
.whereRef('person.ownerId', '=', 'asset.ownerId'), return (eb: ExpressionBuilder<DB, 'asset_face'>) =>
).as('person'); jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.personGroupId', '=', 'asset_face.personGroupId')
.where('person.ownerId', '=', viewingUserId),
).as('person');
}; };
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => { const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
@ -286,15 +294,14 @@ export class PersonRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID, isVisible: true }] })
getFaces(assetId: string, options?: { isVisible?: boolean }) { getFaces(assetId: string, options: GetFacesOptions) {
const isVisible = options === undefined ? true : options.isVisible; const { viewingUserId, isVisible } = options;
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson({ viewingUserId }))
.where('asset_face.assetId', '=', assetId) .where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
@ -302,14 +309,13 @@ export class PersonRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID }] })
getFaceById(id: string) { getFaceById(id: string, { viewingUserId }: WithPersonOptions) {
// TODO return null instead of find or fail // TODO return null instead of find or fail
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson({ viewingUserId }))
.where('asset_face.id', '=', id) .where('asset_face.id', '=', id)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
@ -644,9 +650,11 @@ export class PersonRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }]] }) @GenerateSql({
params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }], { viewingUserId: DummyValue.UUID }],
})
@ChunkedArray() @ChunkedArray()
getFacesByIds(ids: AssetFaceId[]) { getFacesByIds(ids: AssetFaceId[], { viewingUserId }: WithPersonOptions) {
if (ids.length === 0) { if (ids.length === 0) {
return Promise.resolve([]); return Promise.resolve([]);
} }
@ -660,9 +668,8 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson({ viewingUserId }))
.where('asset_face.assetId', 'in', assetIds) .where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personGroupId', 'in', personGroupIds) .where('asset_face.personGroupId', 'in', personGroupIds)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)

View file

@ -835,7 +835,7 @@ export class MediaService extends BaseService {
: undefined; : undefined;
const originalDimensions = getDimensions(asset.exifInfo!); const originalDimensions = getDimensions(asset.exifInfo!);
const assetFaces = await this.personRepository.getFaces(asset.id, {}); const assetFaces = await this.personRepository.getFaces(asset.id, { viewingUserId: asset.ownerId });
const ocrData = await this.ocrRepository.getByAssetId(asset.id, {}); const ocrData = await this.ocrRepository.getByAssetId(asset.id, {});
const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox); const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox);

View file

@ -1446,11 +1446,5 @@ describe(PersonService.name, () => {
it('should not map person if person is null', () => { it('should not map person if person is null', () => {
expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull(); expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull();
}); });
it('should not map person if person does not match auth user id', () => {
expect(
mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person,
).toBeNull();
});
}); });
}); });

View file

@ -87,9 +87,10 @@ export class PersonService extends BaseService {
const result: PersonResponseDto[] = []; const result: PersonResponseDto[] = [];
const changeFeaturePhoto = new Map<string, PersonId>(); const changeFeaturePhoto = new Map<string, PersonId>();
for (const data of dto.data) { for (const data of dto.data) {
const faces = await this.personRepository.getFacesByIds([ const faces = await this.personRepository.getFacesByIds(
{ personGroupId: data.personId, assetId: data.assetId }, [{ personGroupId: data.personId, assetId: data.assetId }],
]); { viewingUserId: auth.user.id },
);
for (const face of faces) { for (const face of faces) {
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] }); await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] });
@ -114,7 +115,7 @@ export class PersonService extends BaseService {
async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> { async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] });
await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] }); await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id); const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id });
const person = await this.findOrFail(auth, personGroupId); const person = await this.findOrFail(auth, personGroupId);
await this.personRepository.reassignFace(face.id, person.personGroupId); await this.personRepository.reassignFace(face.id, person.personGroupId);
@ -130,7 +131,7 @@ export class PersonService extends BaseService {
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> { async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id); const faces = await this.personRepository.getFaces(dto.id, { viewingUserId: auth.user.id, isVisible: true });
const asset = await this.assetRepository.getForFaces(dto.id); const asset = await this.assetRepository.getForFaces(dto.id);
const assetDimensions = getDimensions(asset); const assetDimensions = getDimensions(asset);

View file

@ -57,42 +57,44 @@
); );
</script> </script>
{#if !authManager.isSharedLink && isOwner} {#if !authManager.isSharedLink}
<section class="px-4 pt-4 text-sm"> <section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between"> <div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text> <Text size="small" color="muted">{$t('people')}</Text>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if people.some((person) => person.isHidden)} {#if isOwner}
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton <IconButton
aria-label={$t('show_hidden_people')} aria-label={$t('tag_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye} icon={mdiPlus}
size="medium" size="medium"
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()} onclick={() => assetViewerManager.toggleFaceEditMode()}
/> />
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if faceManager.data.length > 0} {#if faceManager.data.length > 0}
<IconButton <IconButton
aria-label={$t('edit_people')} aria-label={$t('edit_people')}
icon={mdiPencil} icon={mdiPencil}
size="medium" size="medium"
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()} onclick={() => assetViewerManager.openEditFacesPanel()}
/> />
{/if}
{/if} {/if}
</div> </div>
</div> </div>