From e1cc78ca2716cf9b4072205cbb5aec9e938d7e5e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 10 Jun 2026 15:44:18 +0200 Subject: [PATCH] collab --- listify-api/src/audit/audit-log.types.ts | 2 + listify-api/src/auth/auth.controller.ts | 9 + listify-api/src/auth/auth.service.ts | 38 ++- listify-api/src/auth/auth.types.ts | 6 + listify-api/src/auth/user.entity.ts | 4 + .../src/list-templates/list-template.types.ts | 13 + listify-api/src/lists/dto/share-list.dto.ts | 3 + listify-api/src/lists/lists.controller.ts | 27 ++ listify-api/src/lists/lists.module.ts | 9 +- listify-api/src/lists/lists.service.spec.ts | 43 +++ listify-api/src/lists/lists.service.ts | 264 ++++++++++++++++-- .../src/lists/user-list-share.entity.ts | 50 ++++ listify-api/src/lists/user-list.entity.ts | 4 + .../src/app/auth/auth.interceptor.ts | 8 +- listify-client/src/app/auth/auth.models.ts | 6 + listify-client/src/app/auth/auth.service.ts | 8 + .../list-detail/list-detail.component.html | 105 ++++++- .../list-detail/list-detail.component.scss | 51 ++++ .../list-detail/list-detail.component.ts | 95 +++++++ .../src/app/lists/lists.component.html | 9 + listify-client/src/app/lists/lists.models.ts | 13 + listify-client/src/app/lists/lists.service.ts | 8 + 22 files changed, 749 insertions(+), 26 deletions(-) create mode 100644 listify-api/src/lists/dto/share-list.dto.ts create mode 100644 listify-api/src/lists/user-list-share.entity.ts diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index 837ef7e..abdc5e1 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -17,6 +17,8 @@ export type AuditAction = | 'list.created_from_template' | 'list.updated' | 'list.deleted' + | 'list.shared' + | 'list.unshared' | 'list.item_created' | 'list.item_updated' | 'list.item_checked' diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index 23d3617..eca413b 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -55,6 +55,15 @@ export class AuthController { return this.authService.getPublicUser(request.user!.sub); } + @Get('users/search') + @UseGuards(JwtAuthGuard) + searchUsers( + @Req() request: AuthenticatedRequest, + @Query('q') query?: string, + ) { + return this.authService.searchUsers(request.user!.sub, query); + } + @Patch('me/onboarding') @UseGuards(JwtAuthGuard) updateOnboarding( diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index c0f7986..c876786 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -9,7 +9,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; -import { Repository } from 'typeorm'; +import { Like, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; @@ -20,6 +20,7 @@ import { AuthTokens, JwtTokenPayload, PublicUser, + PublicUserSearchResult, } from './auth.types'; import { AppEvents } from '../events/app-events'; import { RefreshTokenEntity } from './refresh-token.entity'; @@ -293,6 +294,41 @@ export class AuthService { return this.toPublicUser(user); } + async searchUsers( + actorUserId: string, + query?: string, + ): Promise { + const normalizedQuery = query?.trim(); + + if (!normalizedQuery || normalizedQuery.length < 2) { + return []; + } + + const pattern = `%${normalizedQuery}%`; + const users = await this.usersRepository.find({ + where: [ + { verified: true, email: Like(pattern) }, + { verified: true, name: Like(pattern) }, + ], + order: { email: 'ASC' }, + take: 10, + }); + + return users + .filter((user) => user.id !== actorUserId) + .filter( + (user, index, allUsers) => + allUsers.findIndex((existingUser) => existingUser.id === user.id) === + index, + ) + .slice(0, 10) + .map((user) => ({ + id: user.id, + email: user.email, + name: user.name ?? undefined, + })); + } + async updateOnboardingCompleted( userId: string, completed: boolean, diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index ee02145..eb4110f 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -37,3 +37,9 @@ export interface PublicUser { verified: boolean; onboardingCompleted: boolean; } + +export interface PublicUserSearchResult { + id: string; + email: string; + name?: string; +} diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index 4295a6d..b91789b 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -9,6 +9,7 @@ import { } from 'typeorm'; import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { UserListEntity } from '../lists/user-list.entity'; +import { UserListShareEntity } from '../lists/user-list-share.entity'; import { RefreshTokenEntity } from './refresh-token.entity'; @Entity('users') @@ -59,4 +60,7 @@ export class UserEntity { @OneToMany(() => UserListEntity, (list) => list.owner) lists?: UserListEntity[]; + + @OneToMany(() => UserListShareEntity, (share) => share.user) + sharedLists?: UserListShareEntity[]; } diff --git a/listify-api/src/list-templates/list-template.types.ts b/listify-api/src/list-templates/list-template.types.ts index e82c1b9..1a5a307 100644 --- a/listify-api/src/list-templates/list-template.types.ts +++ b/listify-api/src/list-templates/list-template.types.ts @@ -38,14 +38,27 @@ export interface UserListItem { updatedAt: string; } +export type UserListAccessRole = 'owner' | 'collaborator'; + +export interface UserListCollaborator { + id: string; + name?: string; + email: string; + role: 'collaborator'; +} + export interface UserList { id: string; ownerId: string; + ownerName?: string; + ownerEmail?: string; + accessRole: UserListAccessRole; sourceTemplateId?: string; name: string; description?: string; kind: ListTemplateKind; items: UserListItem[]; + collaborators: UserListCollaborator[]; createdAt: string; updatedAt: string; } diff --git a/listify-api/src/lists/dto/share-list.dto.ts b/listify-api/src/lists/dto/share-list.dto.ts new file mode 100644 index 0000000..4fe07fa --- /dev/null +++ b/listify-api/src/lists/dto/share-list.dto.ts @@ -0,0 +1,3 @@ +export class ShareListDto { + userId?: string; +} diff --git a/listify-api/src/lists/lists.controller.ts b/listify-api/src/lists/lists.controller.ts index d004429..ac57a15 100644 --- a/listify-api/src/lists/lists.controller.ts +++ b/listify-api/src/lists/lists.controller.ts @@ -16,6 +16,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AuthService } from '../auth/auth.service'; import { CreateListDto } from './dto/create-list.dto'; import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto'; +import { ShareListDto } from './dto/share-list.dto'; import { UpdateListDto } from './dto/update-list.dto'; import { ListRealtimeService } from './list-realtime.service'; import { ListsService } from './lists.service'; @@ -78,6 +79,32 @@ export class ListsController { return this.listsService.deleteList(this.requireUserId(request), listId); } + @Post(':listId/shares') + shareList( + @Req() request: AuthenticatedRequest, + @Param('listId') listId: string, + @Body() shareDto: ShareListDto, + ) { + return this.listsService.shareList( + this.requireUserId(request), + listId, + shareDto, + ); + } + + @Delete(':listId/shares/:userId') + removeShare( + @Req() request: AuthenticatedRequest, + @Param('listId') listId: string, + @Param('userId') userId: string, + ) { + return this.listsService.removeShare( + this.requireUserId(request), + listId, + userId, + ); + } + @Post(':listId/items') addItem( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/lists/lists.module.ts b/listify-api/src/lists/lists.module.ts index 8faa946..cf8dd7f 100644 --- a/listify-api/src/lists/lists.module.ts +++ b/listify-api/src/lists/lists.module.ts @@ -2,17 +2,24 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; +import { UserEntity } from '../auth/user.entity'; import { ListsController } from './lists.controller'; import { ListRealtimeService } from './list-realtime.service'; import { ListsService } from './lists.service'; import { UserListEntity } from './user-list.entity'; import { UserListItemEntity } from './user-list-item.entity'; +import { UserListShareEntity } from './user-list-share.entity'; @Module({ imports: [ AuditModule, AuthModule, - TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]), + TypeOrmModule.forFeature([ + UserEntity, + UserListEntity, + UserListItemEntity, + UserListShareEntity, + ]), ], controllers: [ListsController], providers: [ListRealtimeService, ListsService], diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index aec234b..0bb484b 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -1,18 +1,24 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { UserEntity } from '../auth/user.entity'; import { ListTemplate } from '../list-templates/list-template.types'; import { InMemoryRepository } from '../testing/in-memory-repository'; import { ListRealtimeService } from './list-realtime.service'; import { ListsService } from './lists.service'; import { UserListEntity } from './user-list.entity'; import { UserListItemEntity } from './user-list-item.entity'; +import { UserListShareEntity } from './user-list-share.entity'; describe('ListsService', () => { let service: ListsService; + let usersRepository: InMemoryRepository; beforeEach(() => { + usersRepository = new InMemoryRepository(); service = new ListsService( new InMemoryRepository() as never, new InMemoryRepository() as never, + new InMemoryRepository() as never, + usersRepository as never, ); }); @@ -57,6 +63,8 @@ describe('ListsService', () => { service = new ListsService( new InMemoryRepository() as never, new InMemoryRepository() as never, + new InMemoryRepository() as never, + usersRepository as never, undefined, realtimeService as never, ); @@ -100,6 +108,41 @@ describe('ListsService', () => { ); }); + it('allows owners to share lists with collaborators', async () => { + await usersRepository.save({ + id: 'user-2', + email: 'collaborator@example.com', + passwordHash: 'hash', + verified: true, + onboardingCompleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + const list = await service.createList('user-1', { + name: 'Geteilte Liste', + }); + + const sharedList = await service.shareList('user-1', list.id, { + userId: 'user-2', + }); + const collaboratorList = await service.getList('user-2', list.id); + const updatedByCollaborator = await service.addItem('user-2', list.id, { + title: 'Gemeinsamer Eintrag', + }); + + expect(sharedList.collaborators).toEqual([ + expect.objectContaining({ + id: 'user-2', + email: 'collaborator@example.com', + }), + ]); + expect(collaboratorList.accessRole).toBe('collaborator'); + expect(updatedByCollaborator.items).toHaveLength(1); + await expect(service.deleteList('user-2', list.id)).rejects.toThrow( + ForbiddenException, + ); + }); + it('adds, updates, checks and deletes list items', async () => { const list = await service.createList('user-1', { name: 'Einkauf', diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 6c1d5b1..842fa4f 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -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, @InjectRepository(UserListItemEntity) private readonly listItemsRepository: Repository, + @InjectRepository(UserListShareEntity) + private readonly listSharesRepository: Repository, + @InjectRepository(UserEntity) + private readonly usersRepository: Repository, @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 { - 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(); - 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 { - 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 { - 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 { + 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 { + 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 { - 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 { - 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 { - 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 { 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 { + 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 { + 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 { + 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), }; diff --git a/listify-api/src/lists/user-list-share.entity.ts b/listify-api/src/lists/user-list-share.entity.ts new file mode 100644 index 0000000..05bb723 --- /dev/null +++ b/listify-api/src/lists/user-list-share.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; +import { UserEntity } from '../auth/user.entity'; +import { UserListEntity } from './user-list.entity'; + +export type UserListShareRole = 'collaborator'; + +@Entity('user_list_shares') +@Index(['listId', 'userId'], { unique: true }) +export class UserListShareEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + listId!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + userId!: string; + + @Column({ type: 'varchar', length: 32, default: 'collaborator' }) + role!: UserListShareRole; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @ManyToOne(() => UserListEntity, (list) => list.shares, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'listId' }) + list?: UserListEntity; + + @ManyToOne(() => UserEntity, (user) => user.sharedLists, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId' }) + user?: UserEntity; +} diff --git a/listify-api/src/lists/user-list.entity.ts b/listify-api/src/lists/user-list.entity.ts index dba1b5e..924c1a8 100644 --- a/listify-api/src/lists/user-list.entity.ts +++ b/listify-api/src/lists/user-list.entity.ts @@ -10,6 +10,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { UserEntity } from '../auth/user.entity'; +import { UserListShareEntity } from './user-list-share.entity'; import { UserListItemEntity } from './user-list-item.entity'; import type { ListTemplateKind } from '../list-templates/list-template.types'; @@ -59,4 +60,7 @@ export class UserListEntity { cascade: ['insert', 'update'], }) items!: UserListItemEntity[]; + + @OneToMany(() => UserListShareEntity, (share) => share.list) + shares?: UserListShareEntity[]; } diff --git a/listify-client/src/app/auth/auth.interceptor.ts b/listify-client/src/app/auth/auth.interceptor.ts index 992fc54..d6f9e44 100644 --- a/listify-client/src/app/auth/auth.interceptor.ts +++ b/listify-client/src/app/auth/auth.interceptor.ts @@ -67,5 +67,11 @@ function isApiRequest(request: HttpRequest): boolean { } function isAuthRequest(request: HttpRequest): boolean { - return request.url.startsWith('/api/auth/'); + return [ + '/api/auth/login', + '/api/auth/register', + '/api/auth/refresh', + '/api/auth/resend-verification', + '/api/auth/verify-email', + ].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl)); } diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index 752fd6b..a41befe 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -6,6 +6,12 @@ export interface PublicUser { onboardingCompleted: boolean; } +export interface PublicUserSearchResult { + id: string; + email: string; + name?: string; +} + export interface AuthTokenResponse { accessToken: string; refreshToken: string; diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index 5fd4348..1823f1d 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -5,6 +5,7 @@ import { AuthTokenResponse, LoginRequest, PublicUser, + PublicUserSearchResult, RegisterRequest, RegisterResponse, ResendVerificationResponse, @@ -53,6 +54,13 @@ export class AuthService { .pipe(tap((user) => this.storeUser(user))); } + searchUsers(query: string): Observable { + const params = new HttpParams().set('q', query); + return this.http.get(`${this.apiUrl}/users/search`, { + params, + }); + } + updateOnboardingCompleted(completed: boolean): Observable { return this.http .patch(`${this.apiUrl}/me/onboarding`, { completed }) diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.html b/listify-client/src/app/lists/list-detail/list-detail.component.html index 8fbb637..75d6f89 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.html +++ b/listify-client/src/app/lists/list-detail/list-detail.component.html @@ -8,7 +8,12 @@ @if (isCreateMode()) {

Liste anlegen

} @else if (list()) { -

{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt

+

+ {{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt + @if (list()!.accessRole === 'collaborator') { + - geteilt von {{ list()!.ownerName || list()!.ownerEmail || 'Owner' }} + } +

} @@ -77,6 +82,104 @@ + @if (list() && (showEditor() || list()!.collaborators.length > 0 || list()!.accessRole === 'collaborator')) { + + } + Items diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.scss b/listify-client/src/app/lists/list-detail/list-detail.component.scss index d61eb2f..b2ecec8 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.scss +++ b/listify-client/src/app/lists/list-detail/list-detail.component.scss @@ -34,6 +34,7 @@ .state-card, .editor-card, +.sharing-card, .items-card { min-width: 0; max-width: 100%; @@ -50,6 +51,56 @@ gap: 0.75rem; } +.share-search-field { + width: 100%; +} + +.share-results, +.collaborator-list { + display: grid; + gap: 0.5rem; + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} + +.share-results li, +.collaborator-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + min-width: 0; + padding: 0.6rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); +} + +.share-results span, +.collaborator-list strong, +.collaborator-list span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collaborator-list div { + display: grid; + min-width: 0; +} + +.collaborator-list span { + color: var(--mat-sys-on-surface-variant); + font-size: 0.85rem; +} + +.share-results mat-progress-spinner, +.collaborator-list mat-progress-spinner { + display: inline-flex; + margin-right: 0.5rem; +} + .editor-card mat-card-header button { flex: 0 0 auto; } diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.ts b/listify-client/src/app/lists/list-detail/list-detail.component.ts index b058c55..1d9f697 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.ts +++ b/listify-client/src/app/lists/list-detail/list-detail.component.ts @@ -12,7 +12,9 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { AuthService } from '../../auth/auth.service'; import { getAuthErrorMessage } from '../../auth/error-message'; +import { PublicUserSearchResult } from '../../auth/auth.models'; import { OnboardingService } from '../../onboarding/onboarding.service'; import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models'; import { ListsRealtimeService } from '../lists-realtime.service'; @@ -39,6 +41,7 @@ import { ListsService } from '../lists.service'; export class ListDetailComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly authService = inject(AuthService); private readonly listsService = inject(ListsService); private readonly listsRealtimeService = inject(ListsRealtimeService); private readonly route = inject(ActivatedRoute); @@ -54,7 +57,28 @@ export class ListDetailComponent implements OnInit { protected readonly addingItem = signal(false); protected readonly errorMessage = signal(null); protected readonly updatingItemId = signal(null); + protected readonly shareSearchTerm = signal(''); + protected readonly shareSearchResults = signal([]); + protected readonly searchingUsers = signal(false); + protected readonly sharingUserId = signal(null); + protected readonly removingShareUserId = signal(null); protected readonly canEditItems = computed(() => Boolean(this.list()?.id)); + protected readonly canManageShares = computed( + () => this.list()?.accessRole === 'owner' && !this.isCreateMode(), + ); + protected readonly showShareControls = computed( + () => this.canManageShares() && this.showEditor(), + ); + protected readonly availableShareSearchResults = computed(() => { + const list = this.list(); + const collaboratorIds = new Set( + list?.collaborators.map((collaborator) => collaborator.id) ?? [], + ); + + return this.shareSearchResults().filter( + (user) => user.id !== list?.ownerId && !collaboratorIds.has(user.id), + ); + }); protected readonly showEditor = computed(() => this.isCreateMode() || this.editing()); protected readonly listForm = this.formBuilder.group({ @@ -234,6 +258,77 @@ export class ListDetailComponent implements OnInit { return list.items.filter((item) => item.checked).length; } + protected searchShareUsers(term: string): void { + this.shareSearchTerm.set(term); + + if (term.trim().length < 2) { + this.shareSearchResults.set([]); + return; + } + + this.searchingUsers.set(true); + this.authService + .searchUsers(term) + .pipe(finalize(() => this.searchingUsers.set(false))) + .subscribe({ + next: (users) => this.shareSearchResults.set(users), + error: (error: unknown) => { + this.shareSearchResults.set([]); + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + + protected shareWithUser(user: PublicUserSearchResult): void { + const listId = this.listId(); + + if (!listId || this.sharingUserId()) { + return; + } + + this.sharingUserId.set(user.id); + this.listsService + .shareList(listId, user.id) + .pipe(finalize(() => this.sharingUserId.set(null))) + .subscribe({ + next: (list) => { + this.setList(list, !this.showEditor()); + this.shareSearchTerm.set(''); + this.shareSearchResults.set([]); + this.snackBar.open('Liste geteilt.', 'OK', { duration: 2500 }); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + + protected removeCollaborator(userId: string): void { + const listId = this.listId(); + + if (!listId || this.removingShareUserId()) { + return; + } + + this.removingShareUserId.set(userId); + this.listsService + .removeShare(listId, userId) + .pipe(finalize(() => this.removingShareUserId.set(null))) + .subscribe({ + next: (list) => { + this.setList(list, !this.showEditor()); + this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 }); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + + protected displayUser(user: { name?: string; email: string }): string { + return user.name ? `${user.name} (${user.email})` : user.email; + } + protected async backToLists(): Promise { await this.router.navigateByUrl('/lists'); } diff --git a/listify-client/src/app/lists/lists.component.html b/listify-client/src/app/lists/lists.component.html index 38f0af5..f19fcf6 100644 --- a/listify-client/src/app/lists/lists.component.html +++ b/listify-client/src/app/lists/lists.component.html @@ -124,6 +124,9 @@ {{ list.name }} {{ kindLabel(list.kind) }} - {{ progressLabel(list) }} + @if (list.accessRole === 'collaborator') { + - geteilt von {{ list.ownerName || list.ownerEmail || 'Owner' }} + } @@ -141,6 +144,12 @@ {{ list.updatedAt | date: 'dd.MM.yyyy' }} + @if (list.collaborators.length > 0) { + + + {{ list.collaborators.length }} Mitwirkende + + } @if (list.items.length > 0) { diff --git a/listify-client/src/app/lists/lists.models.ts b/listify-client/src/app/lists/lists.models.ts index bb913a0..28a75ab 100644 --- a/listify-client/src/app/lists/lists.models.ts +++ b/listify-client/src/app/lists/lists.models.ts @@ -16,14 +16,27 @@ export interface UserListItem { updatedAt: string; } +export type UserListAccessRole = 'owner' | 'collaborator'; + +export interface UserListCollaborator { + id: string; + name?: string; + email: string; + role: 'collaborator'; +} + export interface UserList { id: string; ownerId: string; + ownerName?: string; + ownerEmail?: string; + accessRole: UserListAccessRole; sourceTemplateId?: string; name: string; description?: string; kind: ListTemplateKind; items: UserListItem[]; + collaborators: UserListCollaborator[]; createdAt: string; updatedAt: string; } diff --git a/listify-client/src/app/lists/lists.service.ts b/listify-client/src/app/lists/lists.service.ts index 39badf4..23fc0af 100644 --- a/listify-client/src/app/lists/lists.service.ts +++ b/listify-client/src/app/lists/lists.service.ts @@ -30,6 +30,14 @@ export class ListsService { return this.http.patch(`${this.apiUrl}/${listId}`, data); } + shareList(listId: string, userId: string): Observable { + return this.http.post(`${this.apiUrl}/${listId}/shares`, { userId }); + } + + removeShare(listId: string, userId: string): Observable { + return this.http.delete(`${this.apiUrl}/${listId}/shares/${userId}`); + } + addItem(listId: string, data: AddListItemRequest): Observable { return this.http.post(`${this.apiUrl}/${listId}/items`, data); }