mirror of
https://github.com/immich-app/immich.git
synced 2026-09-30 13:23:21 +08:00
feat(server): create all for owner (#31924)
This commit is contained in:
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user