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

This commit is contained in:
Jason Rasmussen
2026-09-29 18:12:25 -04:00
committed by GitHub
parent aec7c0d97b
commit f77847dda0
7 changed files with 167 additions and 18 deletions
+1 -2
View File
@@ -25562,7 +25562,7 @@
"PersonUsersCreateDto": {
"properties": {
"personIds": {
"description": "Person IDs",
"description": "Person IDs, defaults to every person owned by the user",
"items": {
"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)$",
@@ -25585,7 +25585,6 @@
}
},
"required": [
"personIds",
"role",
"sharedWithIds"
],
+2 -2
View File
@@ -2069,8 +2069,8 @@ export type PersonUsersResponseDto = {
sharedWithId: string;
}[];
export type PersonUsersCreateDto = {
/** Person IDs */
personIds: string[];
/** Person IDs, defaults to every person owned by the user */
personIds?: string[];
/** Role that should be applied */
role: PersonUserRole;
/** User IDs that should be given access to the person */
+1 -1
View File
@@ -229,7 +229,7 @@ const PersonUsersSearchSchema = z
const PersonUsersCreateSchema = z
.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'),
role: PersonUserRoleSchema.describe('Role that should be applied'),
})
@@ -66,6 +66,33 @@ set
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
delete from "person_user"
where
@@ -9,6 +9,8 @@ import { SharingDirection } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { PersonUserTable } from 'src/schema/tables/person-user.table.js';
type CreateAllForOwnerOptions = { ownerId: string; sharedWithIds: string[]; role: PersonUserRole };
@Injectable()
export class PersonUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -81,6 +83,41 @@ export class PersonUserRepository {
.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 }]] })
async deleteAll(sharedById: string, dto: PersonUsersDeleteDto) {
if (dto.length === 0) {
@@ -1521,6 +1521,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', () => {
it('should map a face', () => {
const user = UserFactory.create();
+15 -3
View File
@@ -846,11 +846,13 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot share a person with yourself');
}
if (dto.personIds) {
await this.requirePersonAccess({
auth,
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 clusterGroupUsers = await this.clusterGroupRepository.getUsers({
@@ -859,20 +861,30 @@ export class PersonService extends BaseService {
});
const clusterGroupUserIds = new Set(clusterGroupUsers.map(({ id }) => id));
const items: Insertable<PersonUserTable>[] = [];
const sharedById = auth.user.id;
for (const sharedWithId of dto.sharedWithIds) {
if (!clusterGroupUserIds.has(sharedWithId)) {
throw new BadRequestException('All users must be in the same cluster group');
}
}
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) {