feat(server): create all for owner (#31924)

(cherry picked from commit f77847dda0)
This commit is contained in:
Jason Rasmussen
2026-09-29 22:12:49 +00:00
committed by immich-push-o-matic[bot]
parent f3abf9fa40
commit 8851d2957c
7 changed files with 167 additions and 18 deletions
+1 -2
View File
@@ -25547,7 +25547,7 @@
"PersonUsersCreateDto": { "PersonUsersCreateDto": {
"properties": { "properties": {
"personIds": { "personIds": {
"description": "Person IDs", "description": "Person IDs, defaults to every person owned by the user",
"items": { "items": {
"format": "uuid", "format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
@@ -25570,7 +25570,6 @@
} }
}, },
"required": [ "required": [
"personIds",
"role", "role",
"sharedWithIds" "sharedWithIds"
], ],
+2 -2
View File
@@ -2067,8 +2067,8 @@ export type PersonUsersResponseDto = {
sharedWithId: string; sharedWithId: string;
}[]; }[];
export type PersonUsersCreateDto = { export type PersonUsersCreateDto = {
/** Person IDs */ /** Person IDs, defaults to every person owned by the user */
personIds: string[]; personIds?: string[];
/** Role that should be applied */ /** Role that should be applied */
role: PersonUserRole; role: PersonUserRole;
/** User IDs that should be given access to the person */ /** User IDs that should be given access to the person */
+1 -1
View File
@@ -222,7 +222,7 @@ const PersonUsersSearchSchema = z
const PersonUsersCreateSchema = z const PersonUsersCreateSchema = z
.object({ .object({
personIds: uniqueIds.describe('Person IDs'), personIds: uniqueIds.optional().describe('Person IDs, defaults to every person owned by the user'),
sharedWithIds: uniqueIds.describe('User IDs that should be given access to the person'), sharedWithIds: uniqueIds.describe('User IDs that should be given access to the person'),
role: PersonUserRoleSchema.describe('Role that should be applied'), role: PersonUserRoleSchema.describe('Role that should be applied'),
}) })
@@ -66,6 +66,33 @@ set
returning returning
* *
-- PersonUserRepository.createAllForOwner
insert into
"person_user" (
"personGroupId",
"sharedById",
"sharedWithId",
"role"
)
select
"person"."personGroupId" as "personGroupId",
"person"."ownerId" as "sharedById",
"shared"."sharedWithId" as "sharedWithId",
$1::person_user_role_enum as "role"
from
"person"
cross join (
select
unnest($2::uuid[]) as "sharedWithId"
) as "shared"
where
"person"."ownerId" = $3
on conflict ("personGroupId", "sharedById", "sharedWithId") do update
set
"role" = "excluded"."role"
returning
*
-- PersonUserRepository.deleteAll -- PersonUserRepository.deleteAll
delete from "person_user" delete from "person_user"
where where
@@ -9,6 +9,8 @@ import { SharingDirection } from 'src/enum.js';
import { DB } from 'src/schema/index.js'; import { DB } from 'src/schema/index.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js'; import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
type CreateAllForOwnerOptions = { ownerId: string; sharedWithIds: string[]; role: PersonUserRole };
@Injectable() @Injectable()
export class PersonUserRepository { export class PersonUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -81,6 +83,41 @@ export class PersonUserRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, sharedWithIds: [DummyValue.UUID], role: PersonUserRole.Admin }] })
createAllForOwner({ ownerId, sharedWithIds, role }: CreateAllForOwnerOptions) {
if (sharedWithIds.length === 0) {
return [];
}
const shared = sql<{ sharedWithId: string }>`(
select
unnest(${sharedWithIds}::uuid[]) as "sharedWithId"
)`.as('shared');
return this.db
.insertInto('person_user')
.columns(['personGroupId', 'sharedById', 'sharedWithId', 'role'])
.expression((eb) =>
eb
.selectFrom('person')
.crossJoin(shared)
.select(({ ref }) => [
ref('person.personGroupId').as('personGroupId'),
ref('person.ownerId').as('sharedById'),
ref('shared.sharedWithId').as('sharedWithId'),
sql`${role}::person_user_role_enum`.as('role'),
])
.where('person.ownerId', '=', ownerId),
)
.onConflict((oc) =>
oc
.columns(['personGroupId', 'sharedById', 'sharedWithId'])
.doUpdateSet((eb) => ({ role: eb.ref('excluded.role') })),
)
.returningAll()
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [{ personId: DummyValue.UUID, sharedWithId: DummyValue.UUID }]] }) @GenerateSql({ params: [DummyValue.UUID, [{ personId: DummyValue.UUID, sharedWithId: DummyValue.UUID }]] })
async deleteAll(sharedById: string, dto: PersonUsersDeleteDto) { async deleteAll(sharedById: string, dto: PersonUsersDeleteDto) {
if (dto.length === 0) { if (dto.length === 0) {
@@ -1441,6 +1441,80 @@ describe(PersonService.name, () => {
}); });
}); });
describe('addUsersToPeople', () => {
it('should reject sharing a person with yourself', async () => {
const auth = AuthFactory.create();
await expect(
sut.addUsersToPeople(auth, {
personIds: [newUuid()],
sharedWithIds: [auth.user.id],
role: PersonUserRole.Read,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
});
it('should share the given people', async () => {
const auth = AuthFactory.create();
const user = UserFactory.create({ id: auth.user.id });
const sharedWith = UserFactory.create({ clusterGroupId: user.clusterGroupId });
const personId = newUuid();
mocks.access.person.checkAccess.mockResolvedValue(new Set([{ personGroupId: personId, ownerId: auth.user.id }]));
mocks.user.get.mockResolvedValue(user);
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
await sut.addUsersToPeople(auth, {
personIds: [personId],
sharedWithIds: [sharedWith.id],
role: PersonUserRole.Read,
});
expect(mocks.personUser.createAllForOwner).not.toHaveBeenCalled();
expect(mocks.personUser.createAll).toHaveBeenCalledWith([
{ personGroupId: personId, sharedById: auth.user.id, sharedWithId: sharedWith.id, role: PersonUserRole.Read },
]);
});
it('should share every person owned by the user when personIds is omitted', async () => {
const auth = AuthFactory.create();
const user = UserFactory.create({ id: auth.user.id });
const sharedWith = UserFactory.create({ clusterGroupId: user.clusterGroupId });
mocks.user.get.mockResolvedValue(user);
mocks.clusterGroup.getUsers.mockResolvedValue([user, sharedWith]);
await sut.addUsersToPeople(auth, { sharedWithIds: [sharedWith.id], role: PersonUserRole.Write });
expect(mocks.access.person.checkAccess).not.toHaveBeenCalled();
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
expect(mocks.personUser.createAllForOwner).toHaveBeenCalledWith({
ownerId: auth.user.id,
sharedWithIds: [sharedWith.id],
role: PersonUserRole.Write,
});
});
it('should reject users outside the cluster group', async () => {
const auth = AuthFactory.create();
const user = UserFactory.create({ id: auth.user.id });
const outsider = UserFactory.create();
const personId = newUuid();
mocks.access.person.checkAccess.mockResolvedValue(new Set([{ personGroupId: personId, ownerId: auth.user.id }]));
mocks.user.get.mockResolvedValue(user);
mocks.clusterGroup.getUsers.mockResolvedValue([user]);
await expect(
sut.addUsersToPeople(auth, { personIds: [personId], sharedWithIds: [outsider.id], role: PersonUserRole.Read }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.personUser.createAll).not.toHaveBeenCalled();
});
});
describe('mapFace', () => { describe('mapFace', () => {
it('should map a face', () => { it('should map a face', () => {
const user = UserFactory.create(); const user = UserFactory.create();
+25 -13
View File
@@ -833,11 +833,13 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot share a person with yourself'); throw new BadRequestException('Cannot share a person with yourself');
} }
await this.requirePersonAccess({ if (dto.personIds) {
auth, await this.requirePersonAccess({
permission: Permission.PersonUpdate, auth,
ids: dto.personIds.map((item) => ({ personGroupId: item, ownerId: auth.user.id })), permission: Permission.PersonUpdate,
}); ids: dto.personIds.map((item) => ({ personGroupId: item, ownerId: auth.user.id })),
});
}
const user = await findOrFail(() => this.userRepository.get(auth.user.id, {}), 'User'); const user = await findOrFail(() => this.userRepository.get(auth.user.id, {}), 'User');
const clusterGroupUsers = await this.clusterGroupRepository.getUsers({ const clusterGroupUsers = await this.clusterGroupRepository.getUsers({
@@ -846,20 +848,30 @@ export class PersonService extends BaseService {
}); });
const clusterGroupUserIds = new Set(clusterGroupUsers.map(({ id }) => id)); const clusterGroupUserIds = new Set(clusterGroupUsers.map(({ id }) => id));
const items: Insertable<PersonUserTable>[] = [];
const sharedById = auth.user.id;
for (const sharedWithId of dto.sharedWithIds) { for (const sharedWithId of dto.sharedWithIds) {
if (!clusterGroupUserIds.has(sharedWithId)) { if (!clusterGroupUserIds.has(sharedWithId)) {
throw new BadRequestException('All users must be in the same cluster group'); throw new BadRequestException('All users must be in the same cluster group');
} }
for (const personGroupId of dto.personIds) {
items.push({ personGroupId, sharedById, sharedWithId, role: dto.role });
}
} }
await this.personUserRepository.createAll(items); const sharedById = auth.user.id;
if (dto.personIds) {
const items: Insertable<PersonUserTable>[] = [];
for (const sharedWithId of dto.sharedWithIds) {
for (const personGroupId of dto.personIds) {
items.push({ personGroupId, sharedById, sharedWithId, role: dto.role });
}
}
await this.personUserRepository.createAll(items);
} else {
await this.personUserRepository.createAllForOwner({
ownerId: sharedById,
sharedWithIds: dto.sharedWithIds,
role: dto.role,
});
}
} }
async removeUsersFromPeople(auth: AuthDto, dto: PersonUsersDeleteDto) { async removeUsersFromPeople(auth: AuthDto, dto: PersonUsersDeleteDto) {