From 85a43d1b0827f487fdc433975f282b4b0ebf0e37 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 18 Jun 2026 11:18:10 +0200 Subject: [PATCH] template sharing --- listify-api/src/audit/audit-log.types.ts | 2 + listify-api/src/auth/user.entity.ts | 4 + listify-api/src/database/data-source.ts | 2 + .../1781500000000-CreateListTemplateShares.ts | 29 +++ .../list-templates/dto/share-template.dto.ts | 3 + .../list-template-share.entity.ts | 50 ++++ .../list-templates/list-template.entity.ts | 4 + .../src/list-templates/list-template.types.ts | 13 ++ .../list-templates.controller.ts | 27 +++ .../list-templates/list-templates.module.ts | 4 + .../list-templates.service.spec.ts | 55 +++++ .../list-templates/list-templates.service.ts | 218 ++++++++++++++++-- listify-api/src/lists/lists.service.spec.ts | 2 + listify-api/src/lists/lists.service.ts | 8 +- .../template-detail.component.html | 131 +++++++++-- .../template-detail.component.scss | 50 ++++ .../template-detail.component.ts | 101 +++++++- .../app/templates/templates.component.html | 15 +- .../src/app/templates/templates.models.ts | 22 ++ .../src/app/templates/templates.service.ts | 12 + 20 files changed, 714 insertions(+), 38 deletions(-) create mode 100644 listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts create mode 100644 listify-api/src/list-templates/dto/share-template.dto.ts create mode 100644 listify-api/src/list-templates/list-template-share.entity.ts diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index d23cd9d..22edba7 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -10,6 +10,8 @@ export type AuditAction = | 'template.created_from_list' | 'template.updated' | 'template.deleted' + | 'template.shared' + | 'template.unshared' | 'template.item_created' | 'template.item_updated' | 'template.item_deleted' diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index b91789b..03e4223 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -8,6 +8,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { ListTemplateEntity } from '../list-templates/list-template.entity'; +import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListShareEntity } from '../lists/user-list-share.entity'; import { RefreshTokenEntity } from './refresh-token.entity'; @@ -63,4 +64,7 @@ export class UserEntity { @OneToMany(() => UserListShareEntity, (share) => share.user) sharedLists?: UserListShareEntity[]; + + @OneToMany(() => ListTemplateShareEntity, (share) => share.user) + sharedTemplates?: ListTemplateShareEntity[]; } diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index b274a0f..85b8d87 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -7,6 +7,7 @@ import { UserEntity } from '../auth/user.entity'; import { RefreshTokenEntity } from '../auth/refresh-token.entity'; import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity'; +import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity'; import { TemplateSeedEntity } from '../list-templates/template-seed.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListItemEntity } from '../lists/user-list-item.entity'; @@ -35,6 +36,7 @@ export default new DataSource({ RefreshTokenEntity, ListTemplateEntity, ListTemplateItemEntity, + ListTemplateShareEntity, TemplateSeedEntity, UserListEntity, UserListItemEntity, diff --git a/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts b/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts new file mode 100644 index 0000000..0602fd0 --- /dev/null +++ b/listify-api/src/database/migrations/1781500000000-CreateListTemplateShares.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateListTemplateShares1781500000000 + implements MigrationInterface +{ + name = 'CreateListTemplateShares1781500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `list_template_shares` (`id` varchar(36) NOT NULL, `templateId` varchar(36) NOT NULL, `userId` varchar(36) NOT NULL, `role` varchar(32) NOT NULL DEFAULT \'collaborator\', `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), INDEX `IDX_list_template_shares_template_id` (`templateId`), INDEX `IDX_list_template_shares_user_id` (`userId`), UNIQUE INDEX `IDX_list_template_shares_template_user` (`templateId`, `userId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', + ); + await queryRunner.query( + 'ALTER TABLE `list_template_shares` ADD CONSTRAINT `FK_list_template_shares_template_id` FOREIGN KEY (`templateId`) REFERENCES `list_templates`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', + ); + await queryRunner.query( + 'ALTER TABLE `list_template_shares` ADD CONSTRAINT `FK_list_template_shares_user_id` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `list_template_shares` DROP FOREIGN KEY `FK_list_template_shares_user_id`', + ); + await queryRunner.query( + 'ALTER TABLE `list_template_shares` DROP FOREIGN KEY `FK_list_template_shares_template_id`', + ); + await queryRunner.query('DROP TABLE `list_template_shares`'); + } +} diff --git a/listify-api/src/list-templates/dto/share-template.dto.ts b/listify-api/src/list-templates/dto/share-template.dto.ts new file mode 100644 index 0000000..9e3551d --- /dev/null +++ b/listify-api/src/list-templates/dto/share-template.dto.ts @@ -0,0 +1,3 @@ +export class ShareTemplateDto { + userId?: string; +} diff --git a/listify-api/src/list-templates/list-template-share.entity.ts b/listify-api/src/list-templates/list-template-share.entity.ts new file mode 100644 index 0000000..200947a --- /dev/null +++ b/listify-api/src/list-templates/list-template-share.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; +import { UserEntity } from '../auth/user.entity'; +import { ListTemplateEntity } from './list-template.entity'; + +export type ListTemplateShareRole = 'collaborator'; + +@Entity('list_template_shares') +@Index(['templateId', 'userId'], { unique: true }) +export class ListTemplateShareEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + templateId!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + userId!: string; + + @Column({ type: 'varchar', length: 32, default: 'collaborator' }) + role!: ListTemplateShareRole; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @ManyToOne(() => ListTemplateEntity, (template) => template.shares, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'templateId' }) + template?: ListTemplateEntity; + + @ManyToOne(() => UserEntity, (user) => user.sharedTemplates, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId' }) + user?: UserEntity; +} diff --git a/listify-api/src/list-templates/list-template.entity.ts b/listify-api/src/list-templates/list-template.entity.ts index 0c81e28..50bacfa 100644 --- a/listify-api/src/list-templates/list-template.entity.ts +++ b/listify-api/src/list-templates/list-template.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { UserEntity } from '../auth/user.entity'; import { ListTemplateItemEntity } from './list-template-item.entity'; +import { ListTemplateShareEntity } from './list-template-share.entity'; import type { ListTemplateKind } from './list-template.types'; @Entity('list_templates') @@ -60,4 +61,7 @@ export class ListTemplateEntity { cascade: ['insert', 'update'], }) items!: ListTemplateItemEntity[]; + + @OneToMany(() => ListTemplateShareEntity, (share) => share.template) + shares?: ListTemplateShareEntity[]; } diff --git a/listify-api/src/list-templates/list-template.types.ts b/listify-api/src/list-templates/list-template.types.ts index 4ce79a2..4ae26eb 100644 --- a/listify-api/src/list-templates/list-template.types.ts +++ b/listify-api/src/list-templates/list-template.types.ts @@ -14,14 +14,27 @@ export interface ListTemplateItem { export interface ListTemplate { id: string; ownerId: string; + ownerName?: string; + ownerEmail?: string; + accessRole: ListTemplateAccessRole; name: string; description?: string; kind: ListTemplateKind; items: ListTemplateItem[]; + collaborators: ListTemplateCollaborator[]; createdAt: string; updatedAt: string; } +export type ListTemplateAccessRole = 'owner' | 'collaborator'; + +export interface ListTemplateCollaborator { + id: string; + name?: string; + email: string; + role: 'collaborator'; +} + export interface UserListItem { id: string; sourceTemplateItemId?: string; diff --git a/listify-api/src/list-templates/list-templates.controller.ts b/listify-api/src/list-templates/list-templates.controller.ts index b648485..072e165 100644 --- a/listify-api/src/list-templates/list-templates.controller.ts +++ b/listify-api/src/list-templates/list-templates.controller.ts @@ -19,6 +19,7 @@ import { ReorderListTemplateItemsDto, UpdateListTemplateItemDto, } from './dto/list-template-item.dto'; +import { ShareTemplateDto } from './dto/share-template.dto'; import { UpdateListTemplateDto } from './dto/update-list-template.dto'; import { ListTemplatesService } from './list-templates.service'; import type { AuthenticatedRequest } from '../auth/auth.types'; @@ -87,6 +88,32 @@ export class ListTemplatesController { ); } + @Post(':templateId/shares') + shareTemplate( + @Req() request: AuthenticatedRequest, + @Param('templateId') templateId: string, + @Body() shareDto: ShareTemplateDto, + ) { + return this.listTemplatesService.shareTemplate( + this.requireUserId(request), + templateId, + shareDto, + ); + } + + @Delete(':templateId/shares/:userId') + removeShare( + @Req() request: AuthenticatedRequest, + @Param('templateId') templateId: string, + @Param('userId') userId: string, + ) { + return this.listTemplatesService.removeShare( + this.requireUserId(request), + templateId, + userId, + ); + } + @Post(':templateId/items') addItem( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/list-templates/list-templates.module.ts b/listify-api/src/list-templates/list-templates.module.ts index 2faa67b..82a3c3c 100644 --- a/listify-api/src/list-templates/list-templates.module.ts +++ b/listify-api/src/list-templates/list-templates.module.ts @@ -2,10 +2,12 @@ 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 { ListsModule } from '../lists/lists.module'; import { ListTemplatesController } from './list-templates.controller'; import { ListTemplateEntity } from './list-template.entity'; import { ListTemplateItemEntity } from './list-template-item.entity'; +import { ListTemplateShareEntity } from './list-template-share.entity'; import { ListTemplatesService } from './list-templates.service'; import { TemplateSeedEntity } from './template-seed.entity'; @@ -17,7 +19,9 @@ import { TemplateSeedEntity } from './template-seed.entity'; TypeOrmModule.forFeature([ ListTemplateEntity, ListTemplateItemEntity, + ListTemplateShareEntity, TemplateSeedEntity, + UserEntity, ]), ], controllers: [ListTemplatesController], diff --git a/listify-api/src/list-templates/list-templates.service.spec.ts b/listify-api/src/list-templates/list-templates.service.spec.ts index b4484fd..1795bd4 100644 --- a/listify-api/src/list-templates/list-templates.service.spec.ts +++ b/listify-api/src/list-templates/list-templates.service.spec.ts @@ -1,19 +1,25 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { UserEntity } from '../auth/user.entity'; import { InMemoryRepository } from '../testing/in-memory-repository'; import { ListTemplateEntity } from './list-template.entity'; import { ListTemplateItemEntity } from './list-template-item.entity'; +import { ListTemplateShareEntity } from './list-template-share.entity'; import { ListTemplatesService } from './list-templates.service'; import { TemplateSeedEntity } from './template-seed.entity'; describe('ListTemplatesService', () => { let service: ListTemplatesService; let templatesRepository: InMemoryRepository; + let usersRepository: InMemoryRepository; beforeEach(() => { templatesRepository = new InMemoryRepository(); + usersRepository = new InMemoryRepository(); service = new ListTemplatesService( templatesRepository as never, new InMemoryRepository() as never, + new InMemoryRepository() as never, + usersRepository as never, new InMemoryRepository() as never, ); }); @@ -69,6 +75,7 @@ describe('ListTemplatesService', () => { expect(template.name).toBe('Urlaub'); expect(template.kind).toBe('packing'); + expect(template.accessRole).toBe('owner'); expect(template.items).toHaveLength(2); expect(template.items[0].title).toBe('Pass'); expect( @@ -81,6 +88,54 @@ describe('ListTemplatesService', () => { ).toBe(false); }); + it('allows owners to share templates 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 template = await service.createTemplate('user-1', { + name: 'Geteilte Vorlage', + items: [{ title: 'Gemeinsamer Schritt' }], + }); + + const sharedTemplate = await service.shareTemplate('user-1', template.id, { + userId: 'user-2', + }); + const collaboratorTemplate = await service.getTemplate('user-2', template.id); + const updatedByCollaborator = await service.addItem('user-2', template.id, { + title: 'Vom Collaborator', + }); + + expect(sharedTemplate.collaborators).toEqual([ + expect.objectContaining({ + id: 'user-2', + email: 'collaborator@example.com', + }), + ]); + expect(collaboratorTemplate.accessRole).toBe('collaborator'); + expect(updatedByCollaborator.items.map((item) => item.title)).toContain( + 'Vom Collaborator', + ); + expect( + (await service.listTemplates('user-2')).some( + (existingTemplate) => existingTemplate.id === template.id, + ), + ).toBe(true); + await expect(service.deleteTemplate('user-2', template.id)).rejects.toThrow( + ForbiddenException, + ); + + await service.removeShare('user-1', template.id, 'user-2'); + await expect(service.getTemplate('user-2', template.id)).rejects.toThrow( + ForbiddenException, + ); + }); + it('updates template metadata and item content', async () => { const template = await service.createTemplate('user-1', { name: 'Einkauf', diff --git a/listify-api/src/list-templates/list-templates.service.ts b/listify-api/src/list-templates/list-templates.service.ts index 7d5764c..c47beb0 100644 --- a/listify-api/src/list-templates/list-templates.service.ts +++ b/listify-api/src/list-templates/list-templates.service.ts @@ -9,11 +9,13 @@ import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { IsNull, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; +import { UserEntity } from '../auth/user.entity'; import { AddListTemplateItemDto, ReorderListTemplateItemsDto, UpdateListTemplateItemDto, } from './dto/list-template-item.dto'; +import { ShareTemplateDto } from './dto/share-template.dto'; import { CreateListTemplateDto, CreateListTemplateItemDto, @@ -21,11 +23,13 @@ import { import { UpdateListTemplateDto } from './dto/update-list-template.dto'; import { ListTemplate, + ListTemplateAccessRole, ListTemplateItem, ListTemplateKind, } from './list-template.types'; import { ListTemplateEntity } from './list-template.entity'; import { ListTemplateItemEntity } from './list-template-item.entity'; +import { ListTemplateShareEntity } from './list-template-share.entity'; import { TemplateSeedEntity } from './template-seed.entity'; @Injectable() @@ -35,6 +39,10 @@ export class ListTemplatesService { private readonly templatesRepository: Repository, @InjectRepository(ListTemplateItemEntity) private readonly templateItemsRepository: Repository, + @InjectRepository(ListTemplateShareEntity) + private readonly templateSharesRepository: Repository, + @InjectRepository(UserEntity) + private readonly usersRepository: Repository, @InjectRepository(TemplateSeedEntity) private readonly templateSeedsRepository: Repository, @Optional() @@ -69,26 +77,56 @@ export class ListTemplatesService { }, }); - return this.toListTemplate(savedTemplate); + return this.toListTemplate(savedTemplate, ownerId); } async listTemplates(ownerId: string): Promise { await this.ensureExampleTemplates(ownerId); - const templates = await this.templatesRepository.find({ + const ownedTemplates = await this.templatesRepository.find({ where: { ownerId, deletedAt: IsNull() }, - relations: { items: true }, + relations: { items: true, owner: true, shares: { user: true } }, order: { name: 'ASC', items: { position: 'ASC' } }, }); + const sharedTemplateShares = await this.templateSharesRepository.find({ + where: { userId: ownerId }, + relations: { template: { items: true, owner: true, shares: { user: true } } }, + }); + const templatesById = new Map(); - return templates.map((template) => this.toListTemplate(template)); + for (const template of ownedTemplates) { + await this.hydrateTemplateAccessRelations(template); + templatesById.set(template.id, template); + } + + for (const share of sharedTemplateShares) { + const sharedTemplate = + share.template ?? + (await this.templatesRepository.findOne({ + where: { id: share.templateId, deletedAt: IsNull() }, + relations: { items: true, owner: true, shares: { user: true } }, + order: { items: { position: 'ASC' } }, + })); + + if (sharedTemplate && !sharedTemplate.deletedAt) { + await this.hydrateTemplateAccessRelations(sharedTemplate); + templatesById.set(sharedTemplate.id, sharedTemplate); + } + } + + return [...templatesById.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((template) => this.toListTemplate(template, ownerId)); } async getTemplate( ownerId: string, templateId: string, ): Promise { - return this.toListTemplate(await this.findOwnedTemplate(ownerId, templateId)); + return this.toListTemplate( + await this.findAccessibleTemplate(ownerId, templateId), + ownerId, + ); } async updateTemplate( @@ -96,7 +134,7 @@ export class ListTemplatesService { templateId: string, updateDto: UpdateListTemplateDto, ): Promise { - const template = await this.findOwnedTemplate(ownerId, templateId); + const template = await this.findAccessibleTemplate(ownerId, templateId); if (updateDto.name !== undefined) { template.name = this.requireName(updateDto.name); @@ -136,7 +174,7 @@ export class ListTemplatesService { }, }); - return this.toListTemplate(savedTemplate); + return this.toListTemplate(savedTemplate, ownerId); } async deleteTemplate( @@ -163,12 +201,88 @@ export class ListTemplatesService { return { message: 'List template deleted.' }; } + async shareTemplate( + ownerId: string, + templateId: string, + shareDto: ShareTemplateDto, + ): Promise { + const template = await this.findOwnedTemplate(ownerId, templateId); + const targetUserId = this.requireShareUserId(shareDto.userId); + + if (targetUserId === ownerId) { + throw new BadRequestException('Template 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.templateSharesRepository.findOne({ + where: { templateId, userId: targetUserId }, + }); + + if (!existingShare) { + await this.templateSharesRepository.save( + this.templateSharesRepository.create({ + id: randomUUID(), + templateId, + userId: targetUserId, + role: 'collaborator', + }), + ); + } + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.shared', + entityType: 'template', + entityId: template.id, + metadata: { + sharedWithUserId: targetUserId, + sharedWithEmail: targetUser.email, + }, + }); + + return this.getTemplate(ownerId, templateId); + } + + async removeShare( + ownerId: string, + templateId: string, + collaboratorUserId: string, + ): Promise { + const template = await this.findOwnedTemplate(ownerId, templateId); + const existingShare = await this.templateSharesRepository.findOne({ + where: { templateId, userId: collaboratorUserId }, + }); + + if (!existingShare) { + throw new NotFoundException('Template share was not found.'); + } + + await this.templateSharesRepository.remove(existingShare); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.unshared', + entityType: 'template', + entityId: template.id, + metadata: { removedUserId: collaboratorUserId }, + }); + + return this.getTemplate(ownerId, templateId); + } + async addItem( ownerId: string, templateId: string, addDto: AddListTemplateItemDto, ): Promise { - const template = await this.findOwnedTemplate(ownerId, templateId); + const template = await this.findAccessibleTemplate(ownerId, templateId); const item = this.createTemplateItem(addDto, template.items.length); item.templateId = template.id; @@ -198,7 +312,7 @@ export class ListTemplatesService { itemId: string, updateDto: UpdateListTemplateItemDto, ): Promise { - const template = await this.findOwnedTemplate(ownerId, templateId); + const template = await this.findAccessibleTemplate(ownerId, templateId); const item = this.findTemplateItem(template, itemId); if (updateDto.title !== undefined) { @@ -243,7 +357,7 @@ export class ListTemplatesService { templateId: string, reorderDto: ReorderListTemplateItemsDto, ): Promise { - const template = await this.findOwnedTemplate(ownerId, templateId); + const template = await this.findAccessibleTemplate(ownerId, templateId); const itemIds = reorderDto.itemIds; if (!Array.isArray(itemIds)) { @@ -292,7 +406,7 @@ export class ListTemplatesService { templateId: string, itemId: string, ): Promise { - const template = await this.findOwnedTemplate(ownerId, templateId); + const template = await this.findAccessibleTemplate(ownerId, templateId); const itemIndex = template.items.findIndex((item) => item.id === itemId); if (itemIndex === -1) { @@ -323,13 +437,13 @@ export class ListTemplatesService { return this.getTemplate(ownerId, templateId); } - private async findOwnedTemplate( + private async findAccessibleTemplate( ownerId: string, templateId: string, ): Promise { const template = await this.templatesRepository.findOne({ where: { id: templateId, deletedAt: IsNull() }, - relations: { items: true }, + relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, }); @@ -337,11 +451,27 @@ export class ListTemplatesService { throw new NotFoundException('List template was not found.'); } - if (template.ownerId !== ownerId) { + await this.hydrateTemplateAccessRelations(template); + + if (!this.canAccessTemplate(template, ownerId)) { throw new ForbiddenException('List template belongs to another user.'); } template.items = template.items ?? []; + template.shares = template.shares ?? []; + return template; + } + + private async findOwnedTemplate( + ownerId: string, + templateId: string, + ): Promise { + const template = await this.findAccessibleTemplate(ownerId, templateId); + + if (template.ownerId !== ownerId) { + throw new ForbiddenException('Only the template owner can perform this action.'); + } + return template; } @@ -518,16 +648,74 @@ export class ListTemplatesService { return value; } - private toListTemplate(template: ListTemplateEntity): ListTemplate { + private requireShareUserId(userId?: string): string { + const normalizedUserId = userId?.trim(); + + if (!normalizedUserId) { + throw new BadRequestException('User id is required.'); + } + + return normalizedUserId; + } + + private canAccessTemplate(template: ListTemplateEntity, userId: string): boolean { + return ( + template.ownerId === userId || + Boolean(template.shares?.some((share) => share.userId === userId)) + ); + } + + private accessRoleFor( + template: ListTemplateEntity, + viewerId?: string, + ): ListTemplateAccessRole { + return template.ownerId === viewerId ? 'owner' : 'collaborator'; + } + + private async hydrateTemplateAccessRelations( + template: ListTemplateEntity, + ): Promise { + template.owner ??= (await this.usersRepository.findOne({ + where: { id: template.ownerId }, + })) ?? undefined; + + const storedShares = await this.templateSharesRepository.find({ + where: { templateId: template.id }, + relations: { user: true }, + }); + template.shares = storedShares; + + for (const share of template.shares) { + share.user ??= (await this.usersRepository.findOne({ + where: { id: share.userId }, + })) ?? undefined; + } + } + + private toListTemplate( + template: ListTemplateEntity, + viewerId?: string, + ): ListTemplate { return { id: template.id, ownerId: template.ownerId, + ownerName: template.owner?.name ?? undefined, + ownerEmail: template.owner?.email ?? undefined, + accessRole: this.accessRoleFor(template, viewerId), name: template.name, description: template.description ?? undefined, kind: template.kind, items: (template.items ?? []) .sort((left, right) => left.position - right.position) .map((item) => this.toListTemplateItem(item)), + collaborators: (template.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(template.createdAt), updatedAt: this.toIsoString(template.updatedAt), }; diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index 740b5f4..7be5c9e 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -259,8 +259,10 @@ describe('ListsService', () => { const template: ListTemplate = { id: 'template-1', ownerId: 'user-1', + accessRole: 'owner', name: 'Urlaub', kind: 'packing', + collaborators: [], items: [ { id: 'template-item-1', diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index c5a0609..0ba7ddf 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -135,10 +135,6 @@ export class ListsService { template: ListTemplate, createDto: CreateListFromTemplateDto = {}, ): Promise { - if (template.ownerId !== ownerId) { - throw new ForbiddenException('List template belongs to another user.'); - } - const list = this.listsRepository.create({ id: randomUUID(), ownerId, @@ -876,6 +872,9 @@ export class ListsService { return { id: template.id, ownerId: template.ownerId, + ownerName: undefined, + ownerEmail: undefined, + accessRole: 'owner', name: template.name, description: template.description ?? undefined, kind: template.kind, @@ -891,6 +890,7 @@ export class ListsService { createdAt: this.toIsoString(item.createdAt), updatedAt: this.toIsoString(item.updatedAt), })), + collaborators: [], createdAt: this.toIsoString(template.createdAt), updatedAt: this.toIsoString(template.updatedAt), }; diff --git a/listify-client/src/app/templates/template-detail/template-detail.component.html b/listify-client/src/app/templates/template-detail/template-detail.component.html index 0154c59..cdb9f33 100644 --- a/listify-client/src/app/templates/template-detail/template-detail.component.html +++ b/listify-client/src/app/templates/template-detail/template-detail.component.html @@ -1,11 +1,16 @@
-

{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}

-

{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}

+

+ {{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }} + @if (template()?.accessRole === 'collaborator') { + - geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }} + } +

@if (canEditItems()) {
@@ -23,13 +28,15 @@ } Als Liste - + @if (canDeleteTemplate()) { + + }
}
@@ -86,17 +93,115 @@ + @if (template() && (canManageShares() || template()!.collaborators.length > 0 || template()!.accessRole === 'collaborator')) { + + + Freigaben + + @if (canManageShares()) { + {{ template()!.collaborators.length }} Mitwirkende + } @else { + Geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }} + } + + + + + @if (canManageShares()) { + + + @if (availableShareSearchResults().length > 0) { + + } @else if (shareSearchTerm().trim().length >= 2 && !searchingUsers()) { +
+ + Keine passenden User gefunden. +
+ } + } + + @if (template()!.collaborators.length > 0) { +
    + @for (collaborator of template()!.collaborators; track collaborator.id) { +
  • +
    + {{ collaborator.name || collaborator.email }} + @if (collaborator.name) { + {{ collaborator.email }} + } +
    + + @if (canManageShares()) { + + } +
  • + } +
+ } @else { +
+ + Noch keine Mitwirkenden. +
+ } +
+
+ } + Items @if (canEditItems()) { - {{ template()?.items?.length || 0 }} Einträge + {{ template()?.items?.length || 0 }} Eintraege @if (reordering()) { - Reihenfolge wird gespeichert } } @else { - Nach dem Speichern verfügbar + Nach dem Speichern verfuegbar } @@ -119,14 +224,14 @@ } @else { } - Hinzufügen + Hinzufuegen @if (!canEditItems()) {
- Speichere das Template, bevor du Items hinzufügst. + Speichere das Template, bevor du Items hinzufuegst.
} @else if (template()?.items?.length) {
    (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 templateForm = this.formBuilder.group({ name: ['', [Validators.required]], @@ -71,6 +79,22 @@ export class TemplateDetailComponent implements OnInit { required: [true], }); protected readonly canEditItems = computed(() => Boolean(this.template()?.id)); + protected readonly canManageShares = computed( + () => this.template()?.accessRole === 'owner' && !this.isCreateMode(), + ); + protected readonly canDeleteTemplate = computed( + () => this.template()?.accessRole === 'owner' && !this.isCreateMode(), + ); + protected readonly availableShareSearchResults = computed(() => { + const template = this.template(); + const collaboratorIds = new Set( + template?.collaborators.map((collaborator) => collaborator.id) ?? [], + ); + + return this.shareSearchResults().filter( + (user) => user.id !== template?.ownerId && !collaboratorIds.has(user.id), + ); + }); ngOnInit(): void { this.isCreateMode.set(this.templateId() === null); @@ -292,11 +316,7 @@ export class TemplateDetailComponent implements OnInit { const templateId = this.templateId(); const template = this.template(); - if ( - !templateId || - !template || - this.deletingTemplate() - ) { + if (!templateId || !template || !this.canDeleteTemplate() || this.deletingTemplate()) { return; } @@ -338,6 +358,77 @@ export class TemplateDetailComponent implements OnInit { await this.router.navigateByUrl('/templates'); } + 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 templateId = this.templateId(); + + if (!templateId || this.sharingUserId()) { + return; + } + + this.sharingUserId.set(user.id); + this.templatesService + .shareTemplate(templateId, user.id) + .pipe(finalize(() => this.sharingUserId.set(null))) + .subscribe({ + next: (template) => { + this.setTemplate(template); + this.shareSearchTerm.set(''); + this.shareSearchResults.set([]); + this.snackBar.open('Template geteilt.', 'OK', { duration: 2500 }); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + + protected removeCollaborator(userId: string): void { + const templateId = this.templateId(); + + if (!templateId || this.removingShareUserId()) { + return; + } + + this.removingShareUserId.set(userId); + this.templatesService + .removeShare(templateId, userId) + .pipe(finalize(() => this.removingShareUserId.set(null))) + .subscribe({ + next: (template) => { + this.setTemplate(template); + 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; + } + private setTemplate(template: ListTemplate): void { this.template.set(template); this.templateForm.reset({ diff --git a/listify-client/src/app/templates/templates.component.html b/listify-client/src/app/templates/templates.component.html index f0338ef..6c25877 100644 --- a/listify-client/src/app/templates/templates.component.html +++ b/listify-client/src/app/templates/templates.component.html @@ -41,7 +41,12 @@ {{ template.name }} - {{ kindLabel(template.kind) }} + + {{ kindLabel(template.kind) }} + @if (template.accessRole === 'collaborator') { + - geteilt von {{ template.ownerName || template.ownerEmail || 'Owner' }} + } + @@ -58,6 +63,12 @@ {{ template.updatedAt | date: 'dd.MM.yyyy' }} + @if (template.collaborators.length > 0) { + + + {{ template.collaborators.length }} Mitwirkende + + } @if (template.items.length > 0) { @@ -92,6 +103,7 @@ Bearbeiten + @if (template.accessRole === 'owner') { + } } diff --git a/listify-client/src/app/templates/templates.models.ts b/listify-client/src/app/templates/templates.models.ts index cbf4c6d..f946bda 100644 --- a/listify-client/src/app/templates/templates.models.ts +++ b/listify-client/src/app/templates/templates.models.ts @@ -14,14 +14,27 @@ export interface ListTemplateItem { export interface ListTemplate { id: string; ownerId: string; + ownerName?: string; + ownerEmail?: string; + accessRole: ListTemplateAccessRole; name: string; description?: string; kind: ListTemplateKind; items: ListTemplateItem[]; + collaborators: ListTemplateCollaborator[]; createdAt: string; updatedAt: string; } +export type ListTemplateAccessRole = 'owner' | 'collaborator'; + +export interface ListTemplateCollaborator { + id: string; + name?: string; + email: string; + role: 'collaborator'; +} + export interface UserListItem { id: string; sourceTemplateItemId?: string; @@ -38,12 +51,21 @@ export interface UserListItem { export interface UserList { id: string; ownerId: string; + ownerName?: string; + ownerEmail?: string; + accessRole?: 'owner' | 'collaborator'; sourceTemplateId?: string; name: string; description?: string; kind: ListTemplateKind; reminderAt?: string | null; items: UserListItem[]; + collaborators?: Array<{ + id: string; + name?: string; + email: string; + role: 'collaborator'; + }>; createdAt: string; updatedAt: string; } diff --git a/listify-client/src/app/templates/templates.service.ts b/listify-client/src/app/templates/templates.service.ts index c1ad263..44f181a 100644 --- a/listify-client/src/app/templates/templates.service.ts +++ b/listify-client/src/app/templates/templates.service.ts @@ -39,6 +39,18 @@ export class TemplatesService { return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`); } + shareTemplate(templateId: string, userId: string): Observable { + return this.http.post(`${this.apiUrl}/${templateId}/shares`, { + userId, + }); + } + + removeShare(templateId: string, userId: string): Observable { + return this.http.delete( + `${this.apiUrl}/${templateId}/shares/${userId}`, + ); + } + createListFromTemplate( templateId: string, data: CreateListFromTemplateRequest = {},