template sharing

This commit is contained in:
Bastian Wagner
2026-06-18 11:18:10 +02:00
parent f77a592fc8
commit 85a43d1b08
20 changed files with 714 additions and 38 deletions

View File

@@ -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'

View File

@@ -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[];
}

View File

@@ -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,

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateListTemplateShares1781500000000
implements MigrationInterface
{
name = 'CreateListTemplateShares1781500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`');
}
}

View File

@@ -0,0 +1,3 @@
export class ShareTemplateDto {
userId?: string;
}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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],

View File

@@ -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<ListTemplateEntity>;
let usersRepository: InMemoryRepository<UserEntity>;
beforeEach(() => {
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
usersRepository = new InMemoryRepository<UserEntity>();
service = new ListTemplatesService(
templatesRepository as never,
new InMemoryRepository<ListTemplateItemEntity>() as never,
new InMemoryRepository<ListTemplateShareEntity>() as never,
usersRepository as never,
new InMemoryRepository<TemplateSeedEntity>() 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',

View File

@@ -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<ListTemplateEntity>,
@InjectRepository(ListTemplateItemEntity)
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
@InjectRepository(ListTemplateShareEntity)
private readonly templateSharesRepository: Repository<ListTemplateShareEntity>,
@InjectRepository(UserEntity)
private readonly usersRepository: Repository<UserEntity>,
@InjectRepository(TemplateSeedEntity)
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
@Optional()
@@ -69,26 +77,56 @@ export class ListTemplatesService {
},
});
return this.toListTemplate(savedTemplate);
return this.toListTemplate(savedTemplate, ownerId);
}
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
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<string, ListTemplateEntity>();
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplate> {
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<ListTemplateEntity> {
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<ListTemplateEntity> {
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<void> {
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),
};

View File

@@ -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',

View File

@@ -135,10 +135,6 @@ export class ListsService {
template: ListTemplate,
createDto: CreateListFromTemplateDto = {},
): Promise<UserList> {
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),
};