feat: groups

This commit is contained in:
Jason Rasmussen
2025-07-30 18:18:38 -04:00
parent 641a3baadd
commit 4a881022c3
76 changed files with 6515 additions and 124 deletions

View File

@@ -1,5 +1,11 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AlbumGroupCreateAllDto,
AlbumGroupDeleteAllDto,
AlbumGroupResponseDto,
AlbumGroupUpdateDto,
} from 'src/dtos/album-group.dto';
import {
AddUsersDto,
AlbumInfoDto,
@@ -15,7 +21,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
import { GroupIdAndIdParamDto, ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
@ApiTags('Albums')
@Controller('albums')
@@ -86,6 +92,43 @@ export class AlbumController {
return this.service.removeAssets(auth, id, dto);
}
@Get(':id/groups')
@Authenticated({ permission: Permission.AlbumGroupRead })
getGroupsForAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AlbumGroupResponseDto[]> {
return this.service.getGroups(auth, id);
}
@Put(':id/groups')
@Authenticated({ permission: Permission.AlbumGroupCreate })
addGroupsToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AlbumGroupCreateAllDto,
): Promise<AlbumGroupResponseDto[]> {
return this.service.upsertGroups(auth, id, dto);
}
@Delete(':id/groups')
@Authenticated({ permission: Permission.AlbumGroupDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeGroupsFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AlbumGroupDeleteAllDto,
): Promise<void> {
return this.service.removeGroups(auth, id, dto);
}
@Put(':id/groups/:groupId')
@Authenticated({ permission: Permission.AlbumGroupUpdate })
updateAlbumGroup(
@Auth() auth: AuthDto,
@Param() { id, groupId }: GroupIdAndIdParamDto,
@Body() dto: AlbumGroupUpdateDto,
): Promise<AlbumGroupResponseDto> {
return this.service.updateGroup(auth, id, groupId, dto);
}
@Put(':id/users')
@Authenticated({ permission: Permission.AlbumUserCreate })
addUsersToAlbum(

View File

@@ -0,0 +1,85 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { GroupUserCreateAllDto, GroupUserDeleteAllDto, GroupUserResponseDto } from 'src/dtos/group-user.dto';
import {
GroupAdminCreateDto,
GroupAdminResponseDto,
GroupAdminSearchDto,
GroupAdminUpdateDto,
} from 'src/dtos/group.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GroupAdminService } from 'src/services/group-admin.service';
import { UserIdAndIdParamDto, UUIDParamDto } from 'src/validation';
@ApiTags('Groups (admin)')
@Controller('admin/groups')
export class GroupAdminController {
constructor(private service: GroupAdminService) {}
@Get()
@Authenticated({ permission: Permission.AdminGroupRead, admin: true })
searchGroupsAdmin(@Auth() auth: AuthDto, @Query() dto: GroupAdminSearchDto): Promise<GroupAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ permission: Permission.AdminGroupCreate, admin: true })
createGroupAdmin(@Auth() auth: AuthDto, @Body() dto: GroupAdminCreateDto): Promise<GroupAdminResponseDto> {
return this.service.create(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.AdminGroupRead, admin: true })
getGroupAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<GroupAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.AdminGroupUpdate, admin: true })
updateGroupAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: GroupAdminUpdateDto,
): Promise<GroupAdminResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.AdminGroupDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteGroupAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Get(':id/users')
@Authenticated({ permission: Permission.AdminGroupUserRead, admin: true })
getUsersForGroupAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<GroupUserResponseDto[]> {
return this.service.getUsers(auth, id);
}
@Put(':id/users')
@Authenticated({ permission: Permission.AdminGroupUserCreate, admin: true })
addUsersToGroupAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: GroupUserCreateAllDto,
): Promise<GroupUserResponseDto[]> {
return this.service.addUsers(auth, id, dto);
}
@Delete(':id/user')
@Authenticated({ permission: Permission.AdminGroupUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeUsersFromGroupAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: GroupUserDeleteAllDto) {
return this.service.removeUsers(auth, id, dto);
}
@Delete(':id/user/:userId')
@Authenticated({ permission: Permission.AdminGroupUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeUserFromGroupAdmin(@Auth() auth: AuthDto, @Param() { id, userId }: UserIdAndIdParamDto) {
return this.service.removeUser(auth, id, userId);
}
}

View File

@@ -0,0 +1,40 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { GroupUserResponseDto } from 'src/dtos/group-user.dto';
import { GroupResponseDto } from 'src/dtos/group.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GroupService } from 'src/services/group.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Groups')
@Controller('groups')
export class GroupController {
constructor(private service: GroupService) {}
@Get()
@Authenticated({ permission: Permission.GroupRead })
searchMyGroups(@Auth() auth: AuthDto): Promise<GroupResponseDto[]> {
return this.service.search(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.GroupRead })
getMyGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<GroupResponseDto> {
return this.service.get(auth, id);
}
@Delete(':id')
@Authenticated({ permission: Permission.GroupDelete })
@HttpCode(HttpStatus.NO_CONTENT)
leaveMyGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@Get(':id/users')
@Authenticated({ permission: Permission.GroupRead, admin: true })
getMyGroupUsers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<GroupUserResponseDto[]> {
return this.service.getUsers(auth, id);
}
}

View File

@@ -8,6 +8,8 @@ import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { GroupAdminController } from 'src/controllers/group-admin.controller';
import { GroupController } from 'src/controllers/group.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller';
@@ -43,6 +45,8 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
GroupController,
GroupAdminController,
JobController,
LibraryController,
MapController,

View File

@@ -144,6 +144,17 @@ export type UserAdmin = User & {
metadata: UserMetadataItem[];
};
export type Group = {
id: string;
name: string;
description: string | null;
};
export type GroupAdmin = Group & {
createdAt: Date;
updatedAt: Date;
};
export type StorageAsset = {
id: string;
ownerId: string;
@@ -319,6 +330,7 @@ export const columns = {
'shared_link.allowDownload',
'shared_link.password',
],
groupAdmin: ['group.id', 'group.name', 'group.description', 'group.createdAt', 'group.updatedAt'],
user: userColumns,
userWithPrefix: userWithPrefixColumns,
userAdmin: [

View File

@@ -0,0 +1,56 @@
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
import { Group } from 'src/database';
import { GroupResponseDto, mapGroup } from 'src/dtos/group.dto';
import { AlbumUserRole } from 'src/enum';
import { ValidateEnum, ValidateUUID } from 'src/validation';
export class AlbumGroupCreateAllDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AlbumGroupDto)
groups!: AlbumGroupDto[];
}
export class AlbumGroupDeleteAllDto {
@ValidateUUID({ each: true })
groupIds!: string[];
}
export class AlbumGroupDto {
@ValidateUUID()
groupId!: string;
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', optional: true })
role?: AlbumUserRole;
}
export class AlbumGroupUpdateDto {
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole' })
role!: AlbumUserRole;
}
export class AlbumGroupResponseDto extends GroupResponseDto {
metadata!: AlbumGroupMetadata;
}
export class AlbumGroupMetadata {
createdAt!: Date;
updatedAt!: Date;
}
type AlbumGroup = {
createdAt: Date;
updatedAt: Date;
group: Group;
};
export const mapAlbumGroup = (albumGroup: AlbumGroup): AlbumGroupResponseDto => {
return {
...mapGroup(albumGroup.group),
metadata: {
createdAt: albumGroup.createdAt,
updatedAt: albumGroup.updatedAt,
},
};
};

View File

@@ -3,9 +3,10 @@ import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import _ from 'lodash';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AlbumGroupDto } from 'src/dtos/album-group.dto';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
@@ -50,6 +51,12 @@ export class CreateAlbumDto {
@Type(() => AlbumUserCreateDto)
albumUsers?: AlbumUserCreateDto[];
@Optional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => AlbumGroupDto)
groups?: AlbumGroupDto[];
@ValidateUUID({ optional: true, each: true })
assetIds?: string[];
}

View File

@@ -0,0 +1,46 @@
import { ArrayNotEmpty } from 'class-validator';
import { User } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { ValidateUUID } from 'src/validation';
export class GroupUserCreateAllDto {
@ArrayNotEmpty()
users!: GroupUserDto[];
}
export class GroupUserDeleteAllDto {
@ValidateUUID({ each: true })
userIds!: string[];
}
export class GroupUserDto {
@ValidateUUID()
userId!: string;
// TODO potentially add a role UserGroupRole field here
}
export class GroupUserResponseDto extends UserResponseDto {
metadata!: GroupUserMetadata;
}
export class GroupUserMetadata {
createdAt!: Date;
updatedAt!: Date;
}
type GroupUser = {
createdAt: Date;
updatedAt: Date;
user: User;
};
export const mapGroupUser = (groupUser: GroupUser): GroupUserResponseDto => {
return {
...mapUser(groupUser.user),
metadata: {
createdAt: groupUser.createdAt,
updatedAt: groupUser.updatedAt,
},
};
};

View File

@@ -0,0 +1,69 @@
import { Type } from 'class-transformer';
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Group, GroupAdmin } from 'src/database';
import { GroupUserDto } from 'src/dtos/group-user.dto';
import { Optional, ValidateUUID } from 'src/validation';
export class GroupAdminSearchDto {
@ValidateUUID({ optional: true })
id?: string;
@ValidateUUID({ optional: true })
userId?: string;
}
export class GroupAdminCreateDto {
@IsString()
@IsNotEmpty()
name!: string;
@Optional({ nullable: true, emptyToNull: true })
@IsNotEmpty()
@IsString()
description?: string | null;
@Optional()
@ValidateNested({ each: true })
@Type(() => GroupUserDto)
@IsArray()
users?: GroupUserDto[];
}
export class GroupAdminUpdateDto {
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@Optional({ nullable: true, emptyToNull: true })
@IsNotEmpty()
@IsString()
description?: string | null;
}
export class GroupResponseDto {
id!: string;
name!: string;
description!: string | null;
}
export class GroupAdminResponseDto extends GroupResponseDto {
createdAt!: Date;
updatedAt!: Date;
}
export const mapGroup = (group: Group | GroupAdmin) => {
return {
id: group.id,
name: group.name,
description: group.description,
};
};
export const mapGroupAdmin = (group: GroupAdmin) => {
return {
...mapGroup(group),
createdAt: group.createdAt,
updatedAt: group.updatedAt,
};
};

View File

@@ -111,6 +111,11 @@ export enum Permission {
AlbumUserUpdate = 'albumUser.update',
AlbumUserDelete = 'albumUser.delete',
AlbumGroupCreate = 'albumGroup.create',
AlbumGroupRead = 'albumGroup.read',
AlbumGroupUpdate = 'albumGroup.update',
AlbumGroupDelete = 'albumGroup.delete',
AuthChangePassword = 'auth.changePassword',
AuthDeviceDelete = 'authDevice.delete',
@@ -125,6 +130,9 @@ export enum Permission {
FaceUpdate = 'face.update',
FaceDelete = 'face.delete',
GroupRead = 'group.read',
GroupDelete = 'group.delete',
JobCreate = 'job.create',
JobRead = 'job.read',
@@ -230,6 +238,16 @@ export enum Permission {
UserProfileImageUpdate = 'userProfileImage.update',
UserProfileImageDelete = 'userProfileImage.delete',
AdminGroupCreate = 'adminGroup.create',
AdminGroupRead = 'adminGroup.read',
AdminGroupUpdate = 'adminGroup.update',
AdminGroupDelete = 'adminGroup.delete',
AdminGroupUserCreate = 'adminGroupUser.create',
AdminGroupUserRead = 'adminGroupUser.read',
AdminGroupUserUpdate = 'adminGroupUser.update',
AdminGroupUserDelete = 'adminGroupUser.delete',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',

View File

@@ -0,0 +1,87 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AlbumGroupRepository.getAll
select
(
select
to_json(obj)
from
(
select
"group"."id",
"group"."name",
"group"."description"
from
"group"
where
"group"."id" = "album_group"."groupId"
) as obj
) as "group",
"album_group"."createdAt",
"album_group"."updatedAt"
from
"album_group"
inner join "group" on "album_group"."groupId" = "group"."id"
where
"albumId" = $1
order by
"group"."name"
-- AlbumGroupRepository.createAll
insert into
"album_group" ("albumId", "groupId", "role")
values
($1, $2, $3)
on conflict ("albumId", "groupId") do update
set
"role" = "excluded"."role"
returning
"createdAt",
"updatedAt",
(
select
to_json(obj)
from
(
select
"id",
"name",
"description"
from
"group"
where
"album_group"."groupId" = "group"."id"
) as obj
) as "group"
-- AlbumGroupRepository.deleteAll
delete from "album_group"
where
"albumId" = $1
and "groupId" in ($2)
-- AlbumGroupRepository.update
update "album_group"
set
"role" = $1
where
"albumId" = $2
and "groupId" = $3
returning
"createdAt",
"updatedAt",
(
select
to_json(obj)
from
(
select
"id",
"name",
"description"
from
"group"
where
"album_group"."groupId" = "group"."id"
) as obj
) as "group"

View File

@@ -0,0 +1,11 @@
-- NOTE: This file is auto generated by ./sql-generator
-- GroupRepository.search
select
"id",
"name",
"description",
"createdAt",
"updatedAt"
from
"group"

View File

@@ -0,0 +1,65 @@
-- NOTE: This file is auto generated by ./sql-generator
-- GroupUserRepository.getAll
select
(
select
to_json(obj)
from
(
select
"user2"."id",
"user2"."name",
"user2"."email",
"user2"."avatarColor",
"user2"."profileImagePath",
"user2"."profileChangedAt"
from
"user" as "user2"
where
"user2"."id" = "group_user"."userId"
) as obj
) as "user",
"group_user"."createdAt",
"group_user"."updatedAt"
from
"group_user"
inner join "user" on "group_user"."userId" = "user"."id"
where
"groupId" = $1
order by
"user"."name"
-- GroupUserRepository.createAll
insert into
"group_user" ("userId", "groupId")
values
($1, $2)
on conflict do nothing
returning
"createdAt",
"updatedAt",
(
select
to_json(obj)
from
(
select
"user2"."id",
"user2"."name",
"user2"."email",
"user2"."avatarColor",
"user2"."profileImagePath",
"user2"."profileChangedAt"
from
"user" as "user2"
where
"group_user"."userId" = "user2"."id"
) as obj
) as "user"
-- GroupUserRepository.deleteAll
delete from "group_user"
where
"groupId" = $1
and "userId" in ($2)

View File

@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumGroupDto } from 'src/dtos/album-group.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumGroupTable } from 'src/schema/tables/album-group.table';
type AlbumGroup = { albumId: string; groupId: string };
@Injectable()
export class AlbumGroupRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getAll(albumId: string) {
return this.db
.selectFrom('album_group')
.where('albumId', '=', albumId)
.innerJoin('group', 'album_group.groupId', 'group.id')
.orderBy('group.name')
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('group')
.select(['group.id', 'group.name', 'group.description'])
.whereRef('group.id', '=', 'album_group.groupId'),
).as('group'),
)
.$narrowType<{ group: NotNull }>()
.select(['album_group.createdAt', 'album_group.updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [{ groupId: DummyValue.UUID, role: DummyValue.STRING }]] })
@Chunked({ paramIndex: 1 })
createAll(albumId: string, groups: AlbumGroupDto[]) {
return this.db
.insertInto('album_group')
.values(groups.map(({ groupId, role }) => ({ albumId, groupId, role: role ?? AlbumUserRole.Editor })))
.onConflict((oc) =>
oc.columns(['albumId', 'groupId']).doUpdateSet((eb) => ({
role: eb.ref('excluded.role'),
})),
)
.returning(['createdAt', 'updatedAt'])
.returning((eb) =>
jsonObjectFrom(
eb.selectFrom('group').whereRef('album_group.groupId', '=', 'group.id').select(['id', 'name', 'description']),
).as('group'),
)
.$narrowType<{ group: NotNull }>()
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
deleteAll(albumId: string, groupIds: string[]) {
if (groupIds.length === 0) {
return Promise.resolve();
}
return this.db.deleteFrom('album_group').where('albumId', '=', albumId).where('groupId', 'in', groupIds).execute();
}
async exists({ albumId, groupId }: AlbumGroup) {
const albumGroup = await this.db
.selectFrom('album_group')
.select(['albumId'])
.where('albumId', '=', albumId)
.where('groupId', '=', groupId)
.execute();
return !!albumGroup;
}
@GenerateSql({ params: [{ albumId: DummyValue.UUID, groupId: DummyValue.UUID }, { role: DummyValue.STRING }] })
update({ albumId, groupId }: AlbumGroup, dto: Updateable<AlbumGroupTable>) {
return this.db
.updateTable('album_group')
.set(dto)
.where('albumId', '=', albumId)
.where('groupId', '=', groupId)
.returning(['createdAt', 'updatedAt'])
.returning((eb) =>
jsonObjectFrom(
eb.selectFrom('group').whereRef('album_group.groupId', '=', 'group.id').select(['id', 'name', 'description']),
).as('group'),
)
.$narrowType<{ group: NotNull }>()
.executeTakeFirstOrThrow();
}
delete({ albumId, groupId }: AlbumGroup) {
return this.db.deleteFrom('album_group').where('albumId', '=', albumId).where('groupId', '=', groupId).execute();
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
type GroupUser = { groupId: string; userId: string };
@Injectable()
export class GroupUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getAll(groupId: string) {
return this.db
.selectFrom('group_user')
.where('groupId', '=', groupId)
.innerJoin('user', 'group_user.userId', 'user.id')
.orderBy('user.name', 'asc')
.select((eb) =>
jsonObjectFrom(
eb.selectFrom('user as user2').select(columns.userWithPrefix).whereRef('user2.id', '=', 'group_user.userId'),
).as('user'),
)
.$narrowType<{ user: NotNull }>()
.select(['group_user.createdAt', 'group_user.updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
createAll(groupId: string, userIds: string[]) {
return this.db
.insertInto('group_user')
.values(userIds.map((userId) => ({ userId, groupId })))
.onConflict((oc) => oc.doNothing())
.returning(['createdAt', 'updatedAt'])
.returning((eb) =>
jsonObjectFrom(
eb.selectFrom('user as user2').whereRef('group_user.userId', '=', 'user2.id').select(columns.userWithPrefix),
).as('user'),
)
.$narrowType<{ user: NotNull }>()
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
deleteAll(groupId: string, usersIds: string[]) {
if (usersIds.length === 0) {
return Promise.resolve();
}
return this.db.deleteFrom('group_user').where('groupId', '=', groupId).where('userId', 'in', usersIds).execute();
}
get({ groupId, userId }: GroupUser) {
return this.db
.selectFrom('group_user')
.innerJoin('group', 'group.id', 'group_user.groupId')
.select(['group.id', 'group.name', 'group.description'])
.where('group_user.groupId', '=', groupId)
.where('group_user.userId', '=', userId)
.execute();
}
delete({ userId, groupId }: GroupUser) {
return this.db.deleteFrom('group_user').where('userId', '=', userId).where('groupId', '=', groupId).execute();
}
}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { GenerateSql } from 'src/decorators';
import { GroupUserDto } from 'src/dtos/group-user.dto';
import { DB } from 'src/schema';
import { GroupTable } from 'src/schema/tables/group.table';
@Injectable()
export class GroupRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql()
search(options: { id?: string; userId?: string } = {}) {
const { id, userId } = options;
return this.db
.selectFrom('group')
.select(['group.id', 'group.name', 'group.description', 'group.createdAt', 'group.updatedAt'])
.$if(!!id, (eb) => eb.where('group.id', '=', id!))
.$if(!!userId, (eb) =>
eb.innerJoin('group_user', 'group_user.groupId', 'group.id').where('group_user.userId', '=', userId!),
)
.orderBy('group.name', 'asc')
.execute();
}
create(group: Insertable<GroupTable>, users?: GroupUserDto[]) {
return this.db.transaction().execute(async (tx) => {
const newGroup = await tx
.insertInto('group')
.values(group)
.returning(columns.groupAdmin)
.executeTakeFirstOrThrow();
const groupId = newGroup.id;
if (users && users.length > 0) {
await tx
.insertInto('group_user')
.values(users.map(({ userId }) => ({ groupId, userId })))
.execute();
}
return newGroup;
});
}
get(id: string) {
return this.db.selectFrom('group').select(columns.groupAdmin).where('id', '=', id).executeTakeFirst();
}
update(id: string, group: Updateable<GroupTable>) {
return this.db
.updateTable('group')
.set(group)
.where('id', '=', id)
.returning(columns.groupAdmin)
.executeTakeFirstOrThrow();
}
delete(id: string) {
return this.db.deleteFrom('group').where('id', '=', id).executeTakeFirstOrThrow();
}
}

View File

@@ -1,5 +1,6 @@
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumGroupRepository } from 'src/repositories/album-group.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
@@ -14,6 +15,8 @@ import { DownloadRepository } from 'src/repositories/download.repository';
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { GroupUserRepository } from 'src/repositories/group-user.repository';
import { GroupRepository } from 'src/repositories/group.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -48,6 +51,7 @@ export const repositories = [
AccessRepository,
ActivityRepository,
AlbumRepository,
AlbumGroupRepository,
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
@@ -61,6 +65,8 @@ export const repositories = [
DuplicateRepository,
EmailRepository,
EventRepository,
GroupRepository,
GroupUserRepository,
JobRepository,
LibraryRepository,
LoggingRepository,

View File

@@ -165,6 +165,46 @@ export const album_user_delete_audit = registerFunction({
END`,
});
export const album_group_delete_audit = registerFunction({
name: 'album_group_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO "album_audit" ("albumId", "userId")
SELECT OLD."albumId", "group_user"."userId"
FROM OLD INNER JOIN "group_user" ON "group_user"."groupId" = OLD."groupId";
IF pg_trigger_depth() = 1 THEN
INSERT INTO album_group_audit ("albumId", "groupId")
SELECT "albumId", "groupId"
FROM OLD;
END IF;
RETURN NULL;
END`,
});
export const group_user_delete_audit = registerFunction({
name: 'group_user_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO group_audit ("groupId", "userId")
SELECT "groupId", "userId"
FROM OLD;
IF pg_trigger_depth() = 1 THEN
INSERT INTO group_user_audit ("groupId", "userId")
SELECT "groupId", "userId"
FROM OLD;
END IF;
RETURN NULL;
END`,
});
export const memory_delete_audit = registerFunction({
name: 'memory_delete_audit',
returnType: 'TRIGGER',

View File

@@ -22,6 +22,8 @@ import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
import { AlbumGroupAuditTable } from 'src/schema/tables/album-group-audit.table';
import { AlbumGroupTable } from 'src/schema/tables/album-group.table';
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -36,6 +38,10 @@ import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { GroupAuditTable } from 'src/schema/tables/group-audit.table';
import { GroupUserAuditTable } from 'src/schema/tables/group-user-audit.table';
import { GroupUserTable } from 'src/schema/tables/group-user.table';
import { GroupTable } from 'src/schema/tables/group.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
@@ -71,12 +77,14 @@ import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
export class ImmichDatabase {
tables = [
ActivityTable,
AlbumAssetTable,
AlbumAssetAuditTable,
AlbumAuditTable,
AlbumTable,
AlbumGroupTable,
AlbumGroupAuditTable,
AlbumUserAuditTable,
AlbumUserTable,
AlbumTable,
AlbumAssetTable,
AlbumAssetAuditTable,
ApiKeyTable,
AssetAuditTable,
AssetFaceTable,
@@ -88,6 +96,10 @@ export class ImmichDatabase {
AssetExifTable,
FaceSearchTable,
GeodataPlacesTable,
GroupTable,
GroupAuditTable,
GroupUserTable,
GroupUserAuditTable,
LibraryTable,
MemoryTable,
MemoryAuditTable,
@@ -154,6 +166,8 @@ export interface DB {
album_audit: AlbumAuditTable;
album_asset: AlbumAssetTable;
album_asset_audit: AlbumAssetAuditTable;
album_group: AlbumGroupTable;
album_group_audit: AlbumGroupAuditTable;
album_user: AlbumUserTable;
album_user_audit: AlbumUserAuditTable;
@@ -173,6 +187,11 @@ export interface DB {
geodata_places: GeodataPlacesTable;
group: GroupTable;
group_audit: GroupAuditTable;
group_user: GroupUserTable;
group_user_audit: GroupUserAuditTable;
library: LibraryTable;
memory: MemoryTable;

View File

@@ -0,0 +1,118 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION group_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO group_audit ("groupId")
SELECT "id"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION group_user_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO group_audit ("groupId", "userId")
SELECT "groupId", "userId"
FROM OLD;
IF pg_trigger_depth() = 1 THEN
INSERT INTO group_user_audit ("groupId", "userId")
SELECT "groupId", "userId"
FROM OLD;
END IF;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "group_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"groupId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "group_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "group_audit_deletedAt_idx" ON "group_audit" ("deletedAt");`.execute(db);
await sql`CREATE TABLE "group_user_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"groupId" uuid NOT NULL,
"userId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "group_user_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "group_user_audit_groupId_idx" ON "group_user_audit" ("groupId");`.execute(db);
await sql`CREATE INDEX "group_user_audit_userId_idx" ON "group_user_audit" ("userId");`.execute(db);
await sql`CREATE INDEX "group_user_audit_deletedAt_idx" ON "group_user_audit" ("deletedAt");`.execute(db);
await sql`CREATE TABLE "group_user" (
"groupId" uuid NOT NULL,
"userId" uuid NOT NULL,
"createId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "group_user_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "group_user_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "group_user_pkey" PRIMARY KEY ("groupId", "userId")
);`.execute(db);
await sql`CREATE INDEX "group_user_groupId_idx" ON "group_user" ("groupId");`.execute(db);
await sql`CREATE INDEX "group_user_userId_idx" ON "group_user" ("userId");`.execute(db);
await sql`CREATE INDEX "group_user_createId_idx" ON "group_user" ("createId");`.execute(db);
await sql`CREATE INDEX "group_user_updateId_idx" ON "group_user" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "group_user_delete_audit"
AFTER DELETE ON "group_user"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION group_user_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "group_user_updatedAt"
BEFORE UPDATE ON "group_user"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`CREATE TABLE "group" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"description" character varying,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "group_name_uq" UNIQUE ("name"),
CONSTRAINT "group_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "group_updatedAt_id_idx" ON "group" ("updatedAt", "id");`.execute(db);
await sql`CREATE INDEX "group_updateId_idx" ON "group" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "group_delete_audit"
AFTER DELETE ON "group"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION group_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "group_updatedAt"
BEFORE UPDATE ON "group"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_group_delete_audit', '{"type":"function","name":"group_delete_audit","sql":"CREATE OR REPLACE FUNCTION group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO group_audit (\\"groupId\\")\\n SELECT \\"id\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_group_user_delete_audit', '{"type":"function","name":"group_user_delete_audit","sql":"CREATE OR REPLACE FUNCTION group_user_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO group_audit (\\"groupId\\", \\"userId\\")\\n SELECT \\"groupId\\", \\"userId\\"\\n FROM OLD;\\n\\n IF pg_trigger_depth() = 1 THEN\\n INSERT INTO group_user_audit (\\"groupId\\", \\"userId\\")\\n SELECT \\"groupId\\", \\"userId\\"\\n FROM OLD;\\n END IF;\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_group_user_delete_audit', '{"type":"trigger","name":"group_user_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"group_user_delete_audit\\"\\n AFTER DELETE ON \\"group_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION group_user_delete_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_group_user_updatedAt', '{"type":"trigger","name":"group_user_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"group_user_updatedAt\\"\\n BEFORE UPDATE ON \\"group_user\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_group_delete_audit', '{"type":"trigger","name":"group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"group_delete_audit\\"\\n AFTER DELETE ON \\"group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION group_delete_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_group_updatedAt', '{"type":"trigger","name":"group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"group_updatedAt\\"\\n BEFORE UPDATE ON \\"group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "group_audit";`.execute(db);
await sql`DROP TABLE "group_user_audit";`.execute(db);
await sql`DROP TABLE "group_user";`.execute(db);
await sql`DROP TABLE "group";`.execute(db);
await sql`DROP FUNCTION group_delete_audit;`.execute(db);
await sql`DROP FUNCTION group_user_delete_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_group_user_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_group_user_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_group_user_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_group_updatedAt';`.execute(db);
}

View File

@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "group_user" DROP CONSTRAINT "group_user_groupId_fkey";`.execute(db);
await sql`ALTER TABLE "group_user" ADD CONSTRAINT "group_user_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "group" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "group_user" DROP CONSTRAINT "group_user_groupId_fkey";`.execute(db);
await sql`ALTER TABLE "group_user" ADD CONSTRAINT "group_user_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
}

View File

@@ -0,0 +1,33 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "group_delete_audit" ON "group";`.execute(db);
await sql`ALTER TABLE "group_audit" ADD "userId" uuid NOT NULL;`.execute(db);
await sql`DROP FUNCTION group_delete_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_group_delete_audit';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.group_delete_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO group_audit ("groupId")
SELECT "id"
FROM OLD;
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "group_delete_audit"
AFTER DELETE ON "group"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION group_delete_audit();`.execute(db);
await sql`ALTER TABLE "group_audit" DROP COLUMN "userId";`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_group_delete_audit', '{"sql":"CREATE OR REPLACE FUNCTION group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO group_audit (\\"groupId\\")\\n SELECT \\"id\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"group_delete_audit","type":"function"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_group_delete_audit', '{"sql":"CREATE OR REPLACE TRIGGER \\"group_delete_audit\\"\\n AFTER DELETE ON \\"group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION group_delete_audit();","name":"group_delete_audit","type":"trigger"}'::jsonb);`.execute(db);
}

View File

@@ -0,0 +1,70 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_group_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO "album_audit" ("albumId", "userId")
SELECT OLD."albumId", "group_user"."userId"
FROM OLD INNER JOIN "group_user" ON "group_user"."groupId" = OLD."groupId";
IF pg_trigger_depth() = 1 THEN
INSERT INTO album_group_audit ("albumId", "groupId")
SELECT "albumId", "groupId"
FROM OLD;
END IF;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "album_group_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"albumId" uuid NOT NULL,
"groupId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "album_group_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "album_group_audit_albumId_idx" ON "album_group_audit" ("albumId");`.execute(db);
await sql`CREATE INDEX "album_group_audit_groupId_idx" ON "album_group_audit" ("groupId");`.execute(db);
await sql`CREATE INDEX "album_group_audit_deletedAt_idx" ON "album_group_audit" ("deletedAt");`.execute(db);
await sql`CREATE TABLE "album_group" (
"albumId" uuid NOT NULL,
"groupId" uuid NOT NULL,
"role" character varying NOT NULL DEFAULT 'editor',
"createId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "album_group_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "album_group_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "group" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "album_group_pkey" PRIMARY KEY ("albumId", "groupId")
);`.execute(db);
await sql`CREATE INDEX "album_group_albumId_idx" ON "album_group" ("albumId");`.execute(db);
await sql`CREATE INDEX "album_group_groupId_idx" ON "album_group" ("groupId");`.execute(db);
await sql`CREATE INDEX "album_group_createId_idx" ON "album_group" ("createId");`.execute(db);
await sql`CREATE INDEX "album_group_updateId_idx" ON "album_group" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_group_delete_audit"
AFTER DELETE ON "album_group"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION album_group_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_group_updatedAt"
BEFORE UPDATE ON "album_group"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_group_delete_audit', '{"type":"function","name":"album_group_delete_audit","sql":"CREATE OR REPLACE FUNCTION album_group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO \\"album_audit\\" (\\"albumId\\", \\"userId\\")\\n SELECT OLD.\\"albumId\\", \\"group_user\\".\\"userId\\"\\n FROM OLD INNER JOIN \\"group_user\\" ON \\"group_user\\".\\"groupId\\" = OLD.\\"groupId\\";\\n\\n IF pg_trigger_depth() = 1 THEN\\n INSERT INTO album_group_audit (\\"albumId\\", \\"groupId\\")\\n SELECT \\"albumId\\", \\"groupId\\"\\n FROM OLD;\\n END IF;\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_group_delete_audit', '{"type":"trigger","name":"album_group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"album_group_delete_audit\\"\\n AFTER DELETE ON \\"album_group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION album_group_delete_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_group_updatedAt', '{"type":"trigger","name":"album_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"album_group_updatedAt\\"\\n BEFORE UPDATE ON \\"album_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "album_group_audit";`.execute(db);
await sql`DROP TABLE "album_group";`.execute(db);
await sql`DROP FUNCTION album_group_delete_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_group_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_group_updatedAt';`.execute(db);
}

View File

@@ -0,0 +1,17 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('album_group_audit')
export class AlbumGroupAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
albumId!: string;
@Column({ type: 'uuid', index: true })
groupId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View File

@@ -0,0 +1,56 @@
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_group_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { GroupTable } from 'src/schema/tables/group.table';
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table({ name: 'album_group' })
@UpdatedAtTrigger('album_group_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: album_group_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class AlbumGroupTable {
@ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
albumId!: string;
@ForeignKeyColumn(() => GroupTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
groupId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}

View File

@@ -0,0 +1,17 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('group_audit')
export class GroupAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid' })
groupId!: string;
@Column({ type: 'uuid' })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View File

@@ -0,0 +1,17 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('group_user_audit')
export class GroupUserAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
groupId!: string;
@Column({ type: 'uuid', index: true })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View File

@@ -0,0 +1,51 @@
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { group_user_delete_audit } from 'src/schema/functions';
import { GroupTable } from 'src/schema/tables/group.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table({ name: 'group_user' })
@UpdatedAtTrigger('group_user_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: group_user_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class GroupUserTable {
@ForeignKeyColumn(() => GroupTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
groupId!: string;
@ForeignKeyColumn(() => UserTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
userId!: string;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}

View File

@@ -0,0 +1,34 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import {
Column,
CreateDateColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('group')
@UpdatedAtTrigger('group_updatedAt')
@Index({ columns: ['updatedAt', 'id'] })
export class GroupTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ unique: true })
name!: string;
@Column({ nullable: true })
description!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View File

@@ -1,4 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
AlbumGroupCreateAllDto,
AlbumGroupDeleteAllDto,
AlbumGroupResponseDto,
AlbumGroupUpdateDto,
mapAlbumGroup,
} from 'src/dtos/album-group.dto';
import {
AddUsersDto,
AlbumInfoDto,
@@ -204,6 +211,38 @@ export class AlbumService extends BaseService {
return results;
}
async getGroups(auth: AuthDto, id: string): Promise<AlbumGroupResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
const albumGroups = await this.albumGroupRepository.getAll(id);
return albumGroups.map((albumGroup) => mapAlbumGroup(albumGroup));
}
async upsertGroups(auth: AuthDto, id: string, { groups }: AlbumGroupCreateAllDto): Promise<AlbumGroupResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const albumGroups = await this.albumGroupRepository.createAll(id, groups);
return albumGroups.map((albumGroup) => mapAlbumGroup(albumGroup));
}
async removeGroups(auth: AuthDto, id: string, dto: AlbumGroupDeleteAllDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
await this.albumGroupRepository.deleteAll(id, dto.groupIds);
}
async updateGroup(
auth: AuthDto,
id: string,
groupId: string,
dto: AlbumGroupUpdateDto,
): Promise<AlbumGroupResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const exists = await this.albumGroupRepository.exists({ albumId: id, groupId });
if (!exists) {
throw new BadRequestException('Album group not found');
}
const albumGroup = await this.albumGroupRepository.update({ albumId: id, groupId }, { role: dto.role });
return mapAlbumGroup(albumGroup);
}
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });

View File

@@ -7,6 +7,7 @@ import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumGroupRepository } from 'src/repositories/album-group.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
@@ -21,6 +22,8 @@ import { DownloadRepository } from 'src/repositories/download.repository';
import { DuplicateRepository } from 'src/repositories/duplicate.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { GroupUserRepository } from 'src/repositories/group-user.repository';
import { GroupRepository } from 'src/repositories/group.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -111,6 +114,7 @@ export class BaseService {
protected accessRepository: AccessRepository,
protected activityRepository: ActivityRepository,
protected albumRepository: AlbumRepository,
protected albumGroupRepository: AlbumGroupRepository,
protected albumUserRepository: AlbumUserRepository,
protected apiKeyRepository: ApiKeyRepository,
protected assetRepository: AssetRepository,
@@ -124,6 +128,8 @@ export class BaseService {
protected duplicateRepository: DuplicateRepository,
protected emailRepository: EmailRepository,
protected eventRepository: EventRepository,
protected groupRepository: GroupRepository,
protected groupUserRepository: GroupUserRepository,
protected jobRepository: JobRepository,
protected libraryRepository: LibraryRepository,
protected machineLearningRepository: MachineLearningRepository,

View File

@@ -0,0 +1,95 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PostgresError } from 'postgres';
import { AuthDto } from 'src/dtos/auth.dto';
import {
GroupUserCreateAllDto,
GroupUserDeleteAllDto,
GroupUserResponseDto,
mapGroupUser,
} from 'src/dtos/group-user.dto';
import {
GroupAdminCreateDto,
GroupAdminResponseDto,
GroupAdminSearchDto,
GroupAdminUpdateDto,
mapGroupAdmin,
} from 'src/dtos/group.dto';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class GroupAdminService extends BaseService {
async search(auth: AuthDto, dto: GroupAdminSearchDto): Promise<GroupAdminResponseDto[]> {
const groups = await this.groupRepository.search(dto);
return groups.map((group) => mapGroupAdmin(group));
}
async create(auth: AuthDto, dto: GroupAdminCreateDto): Promise<GroupAdminResponseDto> {
try {
const { users, ...groupDto } = dto;
const group = await this.groupRepository.create(groupDto, users);
return mapGroupAdmin(group);
} catch (error) {
this.handleError(error);
}
}
async get(auth: AuthDto, id: string): Promise<GroupAdminResponseDto> {
const group = await this.findOrFail(id);
return mapGroupAdmin(group);
}
async update(auth: AuthDto, id: string, dto: GroupAdminUpdateDto): Promise<GroupAdminResponseDto> {
await this.findOrFail(id);
const updated = await this.groupRepository.update(id, { ...dto, updatedAt: new Date() });
return mapGroupAdmin(updated);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.findOrFail(id);
await this.groupRepository.delete(id);
}
async getUsers(auth: AuthDto, id: string): Promise<GroupUserResponseDto[]> {
await this.findOrFail(id);
const users = await this.groupUserRepository.getAll(id);
return users.map((user) => mapGroupUser(user));
}
async addUsers(auth: AuthDto, id: string, { users }: GroupUserCreateAllDto): Promise<GroupUserResponseDto[]> {
await this.findOrFail(id);
const userIds = users.map(({ userId }) => userId);
const groupUsers = await this.groupUserRepository.createAll(id, userIds);
return groupUsers.map((groupUser) => mapGroupUser(groupUser));
}
async removeUsers(auth: AuthDto, id: string, dto: GroupUserDeleteAllDto): Promise<void> {
await this.findOrFail(id);
await this.groupUserRepository.deleteAll(id, dto.userIds);
}
async removeUser(auth: AuthDto, id: string, userId: string): Promise<void> {
await this.findOrFail(id);
const exists = await this.groupUserRepository.get({ groupId: id, userId });
if (!exists) {
throw new BadRequestException('Group does not include this user');
}
await this.groupUserRepository.delete({ groupId: id, userId });
}
private handleError(error: unknown): never {
if ((error as PostgresError).constraint_name === 'group_name_uq') {
throw new BadRequestException('Group with this name already exists');
}
throw error;
}
private async findOrFail(id: string) {
const group = await this.groupRepository.get(id);
if (!group) {
throw new BadRequestException('Group not found');
}
return group;
}
}

View File

@@ -0,0 +1,38 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { GroupUserResponseDto, mapGroupUser } from 'src/dtos/group-user.dto';
import { GroupResponseDto, mapGroup } from 'src/dtos/group.dto';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class GroupService extends BaseService {
async search(auth: AuthDto): Promise<GroupResponseDto[]> {
const groups = await this.groupRepository.search({ userId: auth.user.id });
return groups.map((group) => mapGroup(group));
}
async get(auth: AuthDto, id: string): Promise<GroupResponseDto> {
const group = await this.findOrFail({ userId: auth.user.id, groupId: id });
return mapGroup(group);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.findOrFail({ userId: auth.user.id, groupId: id });
await this.groupUserRepository.delete({ userId: auth.user.id, groupId: id });
}
async getUsers(auth: AuthDto, id: string): Promise<GroupUserResponseDto[]> {
await this.findOrFail({ userId: auth.user.id, groupId: id });
const users = await this.groupUserRepository.getAll(id);
return users.map((user) => mapGroupUser(user));
}
async findOrFail({ userId, groupId }: { userId: string; groupId: string }): Promise<GroupResponseDto> {
const [group] = await this.groupUserRepository.get({ userId, groupId });
if (!group) {
throw new BadRequestException('Group not found');
}
return group;
}
}

View File

@@ -11,6 +11,8 @@ import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { GroupAdminService } from 'src/services/group-admin.service';
import { GroupService } from 'src/services/group.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MapService } from 'src/services/map.service';
@@ -54,6 +56,8 @@ export const services = [
DatabaseService,
DownloadService,
DuplicateService,
GroupAdminService,
GroupService,
JobService,
LibraryService,
MapService,

View File

@@ -80,12 +80,20 @@ export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => {
};
export class UUIDParamDto {
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
@ValidateUUID()
id!: string;
}
export class UserIdAndIdParamDto extends UUIDParamDto {
@ValidateUUID()
userId!: string;
}
export class GroupIdAndIdParamDto extends UUIDParamDto {
@ValidateUUID()
groupId!: string;
}
export class UUIDAssetIDParamDto {
@ValidateUUID()
id!: string;