collab
This commit is contained in:
@@ -13,15 +13,19 @@ import {
|
||||
ListTemplate,
|
||||
ListTemplateKind,
|
||||
UserList,
|
||||
UserListAccessRole,
|
||||
UserListItem,
|
||||
} from '../list-templates/list-template.types';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto';
|
||||
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
|
||||
import { CreateListDto } from './dto/create-list.dto';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { ShareListDto } from './dto/share-list.dto';
|
||||
import { UpdateListDto } from './dto/update-list.dto';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { UserListItemEntity } from './user-list-item.entity';
|
||||
import { UserListShareEntity } from './user-list-share.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ListsService {
|
||||
@@ -30,6 +34,10 @@ export class ListsService {
|
||||
private readonly listsRepository: Repository<UserListEntity>,
|
||||
@InjectRepository(UserListItemEntity)
|
||||
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
||||
@InjectRepository(UserListShareEntity)
|
||||
private readonly listSharesRepository: Repository<UserListShareEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly usersRepository: Repository<UserEntity>,
|
||||
@Optional()
|
||||
private readonly auditLogService?: AuditLogService,
|
||||
@Optional()
|
||||
@@ -59,8 +67,8 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
const userList = await this.getList(ownerId, savedList.id);
|
||||
await this.publishListSnapshot(savedList.id);
|
||||
|
||||
return userList;
|
||||
}
|
||||
@@ -114,24 +122,51 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
const userList = await this.getList(ownerId, savedList.id);
|
||||
await this.publishListSnapshot(savedList.id);
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
async listLists(ownerId: string): Promise<UserList[]> {
|
||||
const lists = await this.listsRepository.find({
|
||||
const ownedLists = await this.listsRepository.find({
|
||||
where: { ownerId },
|
||||
relations: { items: true },
|
||||
relations: { items: true, owner: true, shares: { user: true } },
|
||||
order: { name: 'ASC', items: { position: 'ASC' } },
|
||||
});
|
||||
const sharedListShares = await this.listSharesRepository.find({
|
||||
where: { userId: ownerId },
|
||||
relations: { list: { items: true, owner: true, shares: { user: true } } },
|
||||
});
|
||||
const listsById = new Map<string, UserListEntity>();
|
||||
|
||||
return lists.map((list) => this.toUserList(list));
|
||||
for (const list of ownedLists) {
|
||||
await this.hydrateListAccessRelations(list);
|
||||
listsById.set(list.id, list);
|
||||
}
|
||||
|
||||
for (const share of sharedListShares) {
|
||||
const sharedList =
|
||||
share.list ??
|
||||
(await this.listsRepository.findOne({
|
||||
where: { id: share.listId },
|
||||
relations: { items: true, owner: true, shares: { user: true } },
|
||||
order: { items: { position: 'ASC' } },
|
||||
}));
|
||||
|
||||
if (sharedList) {
|
||||
await this.hydrateListAccessRelations(sharedList);
|
||||
listsById.set(sharedList.id, sharedList);
|
||||
}
|
||||
}
|
||||
|
||||
return [...listsById.values()]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((list) => this.toUserList(list, ownerId));
|
||||
}
|
||||
|
||||
async getList(ownerId: string, listId: string): Promise<UserList> {
|
||||
return this.toUserList(await this.findOwnedList(ownerId, listId));
|
||||
return this.toUserList(await this.findAccessibleList(ownerId, listId), ownerId);
|
||||
}
|
||||
|
||||
async updateList(
|
||||
@@ -139,7 +174,7 @@ export class ListsService {
|
||||
listId: string,
|
||||
updateDto: UpdateListDto,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
|
||||
if (updateDto.name !== undefined) {
|
||||
list.name = this.requireName(updateDto.name);
|
||||
@@ -169,14 +204,15 @@ export class ListsService {
|
||||
},
|
||||
});
|
||||
|
||||
const userList = this.toUserList(savedList);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, userList);
|
||||
const userList = await this.getList(ownerId, savedList.id);
|
||||
await this.publishListSnapshot(savedList.id);
|
||||
|
||||
return userList;
|
||||
}
|
||||
|
||||
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const accessorIds = this.listAccessorIds(list);
|
||||
const metadata = {
|
||||
name: list.name,
|
||||
kind: list.kind,
|
||||
@@ -192,17 +228,100 @@ export class ListsService {
|
||||
metadata,
|
||||
});
|
||||
|
||||
this.listRealtimeService?.publishDeleted(ownerId, listId);
|
||||
accessorIds.forEach((accessorId) =>
|
||||
this.listRealtimeService?.publishDeleted(accessorId, listId),
|
||||
);
|
||||
|
||||
return { message: 'List deleted.' };
|
||||
}
|
||||
|
||||
async shareList(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
shareDto: ShareListDto,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const targetUserId = this.requireShareUserId(shareDto.userId);
|
||||
|
||||
if (targetUserId === ownerId) {
|
||||
throw new BadRequestException('List owner cannot be added as collaborator.');
|
||||
}
|
||||
|
||||
const targetUser = await this.usersRepository.findOne({
|
||||
where: { id: targetUserId },
|
||||
});
|
||||
|
||||
if (!targetUser || !targetUser.verified) {
|
||||
throw new NotFoundException('User was not found.');
|
||||
}
|
||||
|
||||
const existingShare = await this.listSharesRepository.findOne({
|
||||
where: { listId, userId: targetUserId },
|
||||
});
|
||||
|
||||
if (!existingShare) {
|
||||
await this.listSharesRepository.save(
|
||||
this.listSharesRepository.create({
|
||||
id: randomUUID(),
|
||||
listId,
|
||||
userId: targetUserId,
|
||||
role: 'collaborator',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: ownerId,
|
||||
action: 'list.shared',
|
||||
entityType: 'list',
|
||||
entityId: list.id,
|
||||
metadata: {
|
||||
sharedWithUserId: targetUserId,
|
||||
sharedWithEmail: targetUser.email,
|
||||
},
|
||||
});
|
||||
|
||||
await this.publishListSnapshot(listId);
|
||||
|
||||
return this.getList(ownerId, listId);
|
||||
}
|
||||
|
||||
async removeShare(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
collaboratorUserId: string,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const existingShare = await this.listSharesRepository.findOne({
|
||||
where: { listId, userId: collaboratorUserId },
|
||||
});
|
||||
|
||||
if (!existingShare) {
|
||||
throw new NotFoundException('List share was not found.');
|
||||
}
|
||||
|
||||
await this.listSharesRepository.remove(existingShare);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: ownerId,
|
||||
action: 'list.unshared',
|
||||
entityType: 'list',
|
||||
entityId: list.id,
|
||||
metadata: { removedUserId: collaboratorUserId },
|
||||
});
|
||||
|
||||
this.listRealtimeService?.publishDeleted(collaboratorUserId, listId);
|
||||
await this.publishListSnapshot(listId);
|
||||
|
||||
return this.getList(ownerId, listId);
|
||||
}
|
||||
|
||||
async addItem(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
addDto: AddListItemDto,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
const item = this.createListItem(addDto, list.items.length);
|
||||
|
||||
item.listId = list.id;
|
||||
@@ -224,7 +343,7 @@ export class ListsService {
|
||||
});
|
||||
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
await this.publishListSnapshot(listId);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
@@ -236,7 +355,7 @@ export class ListsService {
|
||||
updateDto: UpdateListItemDto,
|
||||
actorName?: string,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
const item = this.findListItem(list, itemId);
|
||||
const wasChecked = item.checked;
|
||||
|
||||
@@ -297,7 +416,7 @@ export class ListsService {
|
||||
});
|
||||
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
await this.publishListSnapshot(listId);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
@@ -307,7 +426,7 @@ export class ListsService {
|
||||
listId: string,
|
||||
itemId: string,
|
||||
): Promise<UserList> {
|
||||
const list = await this.findOwnedList(ownerId, listId);
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
const itemIndex = list.items.findIndex((item) => item.id === itemId);
|
||||
|
||||
if (itemIndex === -1) {
|
||||
@@ -336,18 +455,18 @@ export class ListsService {
|
||||
});
|
||||
|
||||
const updatedList = await this.getList(ownerId, listId);
|
||||
this.listRealtimeService?.publishSnapshot(ownerId, updatedList);
|
||||
await this.publishListSnapshot(listId);
|
||||
|
||||
return updatedList;
|
||||
}
|
||||
|
||||
private async findOwnedList(
|
||||
private async findAccessibleList(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
): Promise<UserListEntity> {
|
||||
const list = await this.listsRepository.findOne({
|
||||
where: { id: listId },
|
||||
relations: { items: true },
|
||||
relations: { items: true, owner: true, shares: { user: true } },
|
||||
order: { items: { position: 'ASC' } },
|
||||
});
|
||||
|
||||
@@ -355,11 +474,27 @@ export class ListsService {
|
||||
throw new NotFoundException('List was not found.');
|
||||
}
|
||||
|
||||
if (list.ownerId !== ownerId) {
|
||||
await this.hydrateListAccessRelations(list);
|
||||
|
||||
if (!this.canAccessList(list, ownerId)) {
|
||||
throw new ForbiddenException('List belongs to another user.');
|
||||
}
|
||||
|
||||
list.items = list.items ?? [];
|
||||
list.shares = list.shares ?? [];
|
||||
return list;
|
||||
}
|
||||
|
||||
private async findOwnedList(
|
||||
ownerId: string,
|
||||
listId: string,
|
||||
): Promise<UserListEntity> {
|
||||
const list = await this.findAccessibleList(ownerId, listId);
|
||||
|
||||
if (list.ownerId !== ownerId) {
|
||||
throw new ForbiddenException('Only the list owner can perform this action.');
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -465,10 +600,87 @@ export class ListsService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private toUserList(list: UserListEntity): UserList {
|
||||
private requireShareUserId(userId?: string): string {
|
||||
const normalizedUserId = userId?.trim();
|
||||
|
||||
if (!normalizedUserId) {
|
||||
throw new BadRequestException('User id is required.');
|
||||
}
|
||||
|
||||
return normalizedUserId;
|
||||
}
|
||||
|
||||
private canAccessList(list: UserListEntity, userId: string): boolean {
|
||||
return (
|
||||
list.ownerId === userId ||
|
||||
Boolean(list.shares?.some((share) => share.userId === userId))
|
||||
);
|
||||
}
|
||||
|
||||
private accessRoleFor(
|
||||
list: UserListEntity,
|
||||
viewerId?: string,
|
||||
): UserListAccessRole {
|
||||
return list.ownerId === viewerId ? 'owner' : 'collaborator';
|
||||
}
|
||||
|
||||
private listAccessorIds(list: UserListEntity): string[] {
|
||||
return [
|
||||
list.ownerId,
|
||||
...(list.shares ?? []).map((share) => share.userId),
|
||||
].filter((userId, index, userIds) => userIds.indexOf(userId) === index);
|
||||
}
|
||||
|
||||
private async hydrateListAccessRelations(list: UserListEntity): Promise<void> {
|
||||
list.owner ??= (await this.usersRepository.findOne({
|
||||
where: { id: list.ownerId },
|
||||
})) ?? undefined;
|
||||
|
||||
const storedShares = await this.listSharesRepository.find({
|
||||
where: { listId: list.id },
|
||||
relations: { user: true },
|
||||
});
|
||||
list.shares = storedShares;
|
||||
|
||||
for (const share of list.shares) {
|
||||
share.user ??= (await this.usersRepository.findOne({
|
||||
where: { id: share.userId },
|
||||
})) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async publishListSnapshot(listId: string): Promise<void> {
|
||||
if (!this.listRealtimeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = await this.listsRepository.findOne({
|
||||
where: { id: listId },
|
||||
relations: { items: true, owner: true, shares: { user: true } },
|
||||
order: { items: { position: 'ASC' } },
|
||||
});
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.hydrateListAccessRelations(list);
|
||||
|
||||
this.listAccessorIds(list).forEach((accessorId) => {
|
||||
this.listRealtimeService?.publishSnapshot(
|
||||
accessorId,
|
||||
this.toUserList(list, accessorId),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private toUserList(list: UserListEntity, viewerId?: string): UserList {
|
||||
return {
|
||||
id: list.id,
|
||||
ownerId: list.ownerId,
|
||||
ownerName: list.owner?.name ?? undefined,
|
||||
ownerEmail: list.owner?.email ?? undefined,
|
||||
accessRole: this.accessRoleFor(list, viewerId),
|
||||
sourceTemplateId: list.sourceTemplateId ?? undefined,
|
||||
name: list.name,
|
||||
description: list.description ?? undefined,
|
||||
@@ -476,6 +688,14 @@ export class ListsService {
|
||||
items: (list.items ?? [])
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.map((item) => this.toUserListItem(item)),
|
||||
collaborators: (list.shares ?? [])
|
||||
.filter((share) => Boolean(share.user))
|
||||
.map((share) => ({
|
||||
id: share.userId,
|
||||
name: share.user?.name ?? undefined,
|
||||
email: share.user!.email,
|
||||
role: 'collaborator',
|
||||
})),
|
||||
createdAt: this.toIsoString(list.createdAt),
|
||||
updatedAt: this.toIsoString(list.updatedAt),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user