template sharing
This commit is contained in:
@@ -10,6 +10,8 @@ export type AuditAction =
|
|||||||
| 'template.created_from_list'
|
| 'template.created_from_list'
|
||||||
| 'template.updated'
|
| 'template.updated'
|
||||||
| 'template.deleted'
|
| 'template.deleted'
|
||||||
|
| 'template.shared'
|
||||||
|
| 'template.unshared'
|
||||||
| 'template.item_created'
|
| 'template.item_created'
|
||||||
| 'template.item_updated'
|
| 'template.item_updated'
|
||||||
| 'template.item_deleted'
|
| 'template.item_deleted'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
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 { UserListEntity } from '../lists/user-list.entity';
|
||||||
import { UserListShareEntity } from '../lists/user-list-share.entity';
|
import { UserListShareEntity } from '../lists/user-list-share.entity';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
@@ -63,4 +64,7 @@ export class UserEntity {
|
|||||||
|
|
||||||
@OneToMany(() => UserListShareEntity, (share) => share.user)
|
@OneToMany(() => UserListShareEntity, (share) => share.user)
|
||||||
sharedLists?: UserListShareEntity[];
|
sharedLists?: UserListShareEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => ListTemplateShareEntity, (share) => share.user)
|
||||||
|
sharedTemplates?: ListTemplateShareEntity[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UserEntity } from '../auth/user.entity';
|
|||||||
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
import { RefreshTokenEntity } from '../auth/refresh-token.entity';
|
||||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||||
import { ListTemplateItemEntity } from '../list-templates/list-template-item.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 { TemplateSeedEntity } from '../list-templates/template-seed.entity';
|
||||||
import { UserListEntity } from '../lists/user-list.entity';
|
import { UserListEntity } from '../lists/user-list.entity';
|
||||||
import { UserListItemEntity } from '../lists/user-list-item.entity';
|
import { UserListItemEntity } from '../lists/user-list-item.entity';
|
||||||
@@ -35,6 +36,7 @@ export default new DataSource({
|
|||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
ListTemplateItemEntity,
|
ListTemplateItemEntity,
|
||||||
|
ListTemplateShareEntity,
|
||||||
TemplateSeedEntity,
|
TemplateSeedEntity,
|
||||||
UserListEntity,
|
UserListEntity,
|
||||||
UserListItemEntity,
|
UserListItemEntity,
|
||||||
|
|||||||
@@ -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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
3
listify-api/src/list-templates/dto/share-template.dto.ts
Normal file
3
listify-api/src/list-templates/dto/share-template.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export class ShareTemplateDto {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
50
listify-api/src/list-templates/list-template-share.entity.ts
Normal file
50
listify-api/src/list-templates/list-template-share.entity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { ListTemplateItemEntity } from './list-template-item.entity';
|
import { ListTemplateItemEntity } from './list-template-item.entity';
|
||||||
|
import { ListTemplateShareEntity } from './list-template-share.entity';
|
||||||
import type { ListTemplateKind } from './list-template.types';
|
import type { ListTemplateKind } from './list-template.types';
|
||||||
|
|
||||||
@Entity('list_templates')
|
@Entity('list_templates')
|
||||||
@@ -60,4 +61,7 @@ export class ListTemplateEntity {
|
|||||||
cascade: ['insert', 'update'],
|
cascade: ['insert', 'update'],
|
||||||
})
|
})
|
||||||
items!: ListTemplateItemEntity[];
|
items!: ListTemplateItemEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => ListTemplateShareEntity, (share) => share.template)
|
||||||
|
shares?: ListTemplateShareEntity[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,27 @@ export interface ListTemplateItem {
|
|||||||
export interface ListTemplate {
|
export interface ListTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
ownerEmail?: string;
|
||||||
|
accessRole: ListTemplateAccessRole;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
items: ListTemplateItem[];
|
items: ListTemplateItem[];
|
||||||
|
collaborators: ListTemplateCollaborator[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListTemplateAccessRole = 'owner' | 'collaborator';
|
||||||
|
|
||||||
|
export interface ListTemplateCollaborator {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
role: 'collaborator';
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserListItem {
|
export interface UserListItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceTemplateItemId?: string;
|
sourceTemplateItemId?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ReorderListTemplateItemsDto,
|
ReorderListTemplateItemsDto,
|
||||||
UpdateListTemplateItemDto,
|
UpdateListTemplateItemDto,
|
||||||
} from './dto/list-template-item.dto';
|
} from './dto/list-template-item.dto';
|
||||||
|
import { ShareTemplateDto } from './dto/share-template.dto';
|
||||||
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
|
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
|
||||||
import { ListTemplatesService } from './list-templates.service';
|
import { ListTemplatesService } from './list-templates.service';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
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')
|
@Post(':templateId/items')
|
||||||
addItem(
|
addItem(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { ListsModule } from '../lists/lists.module';
|
import { ListsModule } from '../lists/lists.module';
|
||||||
import { ListTemplatesController } from './list-templates.controller';
|
import { ListTemplatesController } from './list-templates.controller';
|
||||||
import { ListTemplateEntity } from './list-template.entity';
|
import { ListTemplateEntity } from './list-template.entity';
|
||||||
import { ListTemplateItemEntity } from './list-template-item.entity';
|
import { ListTemplateItemEntity } from './list-template-item.entity';
|
||||||
|
import { ListTemplateShareEntity } from './list-template-share.entity';
|
||||||
import { ListTemplatesService } from './list-templates.service';
|
import { ListTemplatesService } from './list-templates.service';
|
||||||
import { TemplateSeedEntity } from './template-seed.entity';
|
import { TemplateSeedEntity } from './template-seed.entity';
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@ import { TemplateSeedEntity } from './template-seed.entity';
|
|||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
ListTemplateItemEntity,
|
ListTemplateItemEntity,
|
||||||
|
ListTemplateShareEntity,
|
||||||
TemplateSeedEntity,
|
TemplateSeedEntity,
|
||||||
|
UserEntity,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ListTemplatesController],
|
controllers: [ListTemplatesController],
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
import { ListTemplateEntity } from './list-template.entity';
|
import { ListTemplateEntity } from './list-template.entity';
|
||||||
import { ListTemplateItemEntity } from './list-template-item.entity';
|
import { ListTemplateItemEntity } from './list-template-item.entity';
|
||||||
|
import { ListTemplateShareEntity } from './list-template-share.entity';
|
||||||
import { ListTemplatesService } from './list-templates.service';
|
import { ListTemplatesService } from './list-templates.service';
|
||||||
import { TemplateSeedEntity } from './template-seed.entity';
|
import { TemplateSeedEntity } from './template-seed.entity';
|
||||||
|
|
||||||
describe('ListTemplatesService', () => {
|
describe('ListTemplatesService', () => {
|
||||||
let service: ListTemplatesService;
|
let service: ListTemplatesService;
|
||||||
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
||||||
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
|
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
|
||||||
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
service = new ListTemplatesService(
|
service = new ListTemplatesService(
|
||||||
templatesRepository as never,
|
templatesRepository as never,
|
||||||
new InMemoryRepository<ListTemplateItemEntity>() as never,
|
new InMemoryRepository<ListTemplateItemEntity>() as never,
|
||||||
|
new InMemoryRepository<ListTemplateShareEntity>() as never,
|
||||||
|
usersRepository as never,
|
||||||
new InMemoryRepository<TemplateSeedEntity>() as never,
|
new InMemoryRepository<TemplateSeedEntity>() as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -69,6 +75,7 @@ describe('ListTemplatesService', () => {
|
|||||||
|
|
||||||
expect(template.name).toBe('Urlaub');
|
expect(template.name).toBe('Urlaub');
|
||||||
expect(template.kind).toBe('packing');
|
expect(template.kind).toBe('packing');
|
||||||
|
expect(template.accessRole).toBe('owner');
|
||||||
expect(template.items).toHaveLength(2);
|
expect(template.items).toHaveLength(2);
|
||||||
expect(template.items[0].title).toBe('Pass');
|
expect(template.items[0].title).toBe('Pass');
|
||||||
expect(
|
expect(
|
||||||
@@ -81,6 +88,54 @@ describe('ListTemplatesService', () => {
|
|||||||
).toBe(false);
|
).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 () => {
|
it('updates template metadata and item content', async () => {
|
||||||
const template = await service.createTemplate('user-1', {
|
const template = await service.createTemplate('user-1', {
|
||||||
name: 'Einkauf',
|
name: 'Einkauf',
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import {
|
import {
|
||||||
AddListTemplateItemDto,
|
AddListTemplateItemDto,
|
||||||
ReorderListTemplateItemsDto,
|
ReorderListTemplateItemsDto,
|
||||||
UpdateListTemplateItemDto,
|
UpdateListTemplateItemDto,
|
||||||
} from './dto/list-template-item.dto';
|
} from './dto/list-template-item.dto';
|
||||||
|
import { ShareTemplateDto } from './dto/share-template.dto';
|
||||||
import {
|
import {
|
||||||
CreateListTemplateDto,
|
CreateListTemplateDto,
|
||||||
CreateListTemplateItemDto,
|
CreateListTemplateItemDto,
|
||||||
@@ -21,11 +23,13 @@ import {
|
|||||||
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
|
import { UpdateListTemplateDto } from './dto/update-list-template.dto';
|
||||||
import {
|
import {
|
||||||
ListTemplate,
|
ListTemplate,
|
||||||
|
ListTemplateAccessRole,
|
||||||
ListTemplateItem,
|
ListTemplateItem,
|
||||||
ListTemplateKind,
|
ListTemplateKind,
|
||||||
} from './list-template.types';
|
} from './list-template.types';
|
||||||
import { ListTemplateEntity } from './list-template.entity';
|
import { ListTemplateEntity } from './list-template.entity';
|
||||||
import { ListTemplateItemEntity } from './list-template-item.entity';
|
import { ListTemplateItemEntity } from './list-template-item.entity';
|
||||||
|
import { ListTemplateShareEntity } from './list-template-share.entity';
|
||||||
import { TemplateSeedEntity } from './template-seed.entity';
|
import { TemplateSeedEntity } from './template-seed.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -35,6 +39,10 @@ export class ListTemplatesService {
|
|||||||
private readonly templatesRepository: Repository<ListTemplateEntity>,
|
private readonly templatesRepository: Repository<ListTemplateEntity>,
|
||||||
@InjectRepository(ListTemplateItemEntity)
|
@InjectRepository(ListTemplateItemEntity)
|
||||||
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
|
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
|
||||||
|
@InjectRepository(ListTemplateShareEntity)
|
||||||
|
private readonly templateSharesRepository: Repository<ListTemplateShareEntity>,
|
||||||
|
@InjectRepository(UserEntity)
|
||||||
|
private readonly usersRepository: Repository<UserEntity>,
|
||||||
@InjectRepository(TemplateSeedEntity)
|
@InjectRepository(TemplateSeedEntity)
|
||||||
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
|
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
|
||||||
@Optional()
|
@Optional()
|
||||||
@@ -69,26 +77,56 @@ export class ListTemplatesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toListTemplate(savedTemplate);
|
return this.toListTemplate(savedTemplate, ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
|
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
|
||||||
await this.ensureExampleTemplates(ownerId);
|
await this.ensureExampleTemplates(ownerId);
|
||||||
|
|
||||||
const templates = await this.templatesRepository.find({
|
const ownedTemplates = await this.templatesRepository.find({
|
||||||
where: { ownerId, deletedAt: IsNull() },
|
where: { ownerId, deletedAt: IsNull() },
|
||||||
relations: { items: true },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { name: 'ASC', items: { position: 'ASC' } },
|
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(
|
async getTemplate(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<ListTemplate> {
|
): Promise<ListTemplate> {
|
||||||
return this.toListTemplate(await this.findOwnedTemplate(ownerId, templateId));
|
return this.toListTemplate(
|
||||||
|
await this.findAccessibleTemplate(ownerId, templateId),
|
||||||
|
ownerId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTemplate(
|
async updateTemplate(
|
||||||
@@ -96,7 +134,7 @@ export class ListTemplatesService {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
updateDto: UpdateListTemplateDto,
|
updateDto: UpdateListTemplateDto,
|
||||||
): Promise<ListTemplate> {
|
): Promise<ListTemplate> {
|
||||||
const template = await this.findOwnedTemplate(ownerId, templateId);
|
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||||
|
|
||||||
if (updateDto.name !== undefined) {
|
if (updateDto.name !== undefined) {
|
||||||
template.name = this.requireName(updateDto.name);
|
template.name = this.requireName(updateDto.name);
|
||||||
@@ -136,7 +174,7 @@ export class ListTemplatesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.toListTemplate(savedTemplate);
|
return this.toListTemplate(savedTemplate, ownerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTemplate(
|
async deleteTemplate(
|
||||||
@@ -163,12 +201,88 @@ export class ListTemplatesService {
|
|||||||
return { message: 'List template deleted.' };
|
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(
|
async addItem(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
addDto: AddListTemplateItemDto,
|
addDto: AddListTemplateItemDto,
|
||||||
): Promise<ListTemplate> {
|
): Promise<ListTemplate> {
|
||||||
const template = await this.findOwnedTemplate(ownerId, templateId);
|
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||||
const item = this.createTemplateItem(addDto, template.items.length);
|
const item = this.createTemplateItem(addDto, template.items.length);
|
||||||
|
|
||||||
item.templateId = template.id;
|
item.templateId = template.id;
|
||||||
@@ -198,7 +312,7 @@ export class ListTemplatesService {
|
|||||||
itemId: string,
|
itemId: string,
|
||||||
updateDto: UpdateListTemplateItemDto,
|
updateDto: UpdateListTemplateItemDto,
|
||||||
): Promise<ListTemplate> {
|
): Promise<ListTemplate> {
|
||||||
const template = await this.findOwnedTemplate(ownerId, templateId);
|
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||||
const item = this.findTemplateItem(template, itemId);
|
const item = this.findTemplateItem(template, itemId);
|
||||||
|
|
||||||
if (updateDto.title !== undefined) {
|
if (updateDto.title !== undefined) {
|
||||||
@@ -243,7 +357,7 @@ export class ListTemplatesService {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
reorderDto: ReorderListTemplateItemsDto,
|
reorderDto: ReorderListTemplateItemsDto,
|
||||||
): Promise<ListTemplate> {
|
): Promise<ListTemplate> {
|
||||||
const template = await this.findOwnedTemplate(ownerId, templateId);
|
const template = await this.findAccessibleTemplate(ownerId, templateId);
|
||||||
const itemIds = reorderDto.itemIds;
|
const itemIds = reorderDto.itemIds;
|
||||||
|
|
||||||
if (!Array.isArray(itemIds)) {
|
if (!Array.isArray(itemIds)) {
|
||||||
@@ -292,7 +406,7 @@ export class ListTemplatesService {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
): Promise<ListTemplate> {
|
): 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);
|
const itemIndex = template.items.findIndex((item) => item.id === itemId);
|
||||||
|
|
||||||
if (itemIndex === -1) {
|
if (itemIndex === -1) {
|
||||||
@@ -323,13 +437,13 @@ export class ListTemplatesService {
|
|||||||
return this.getTemplate(ownerId, templateId);
|
return this.getTemplate(ownerId, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOwnedTemplate(
|
private async findAccessibleTemplate(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<ListTemplateEntity> {
|
): Promise<ListTemplateEntity> {
|
||||||
const template = await this.templatesRepository.findOne({
|
const template = await this.templatesRepository.findOne({
|
||||||
where: { id: templateId, deletedAt: IsNull() },
|
where: { id: templateId, deletedAt: IsNull() },
|
||||||
relations: { items: true },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { items: { position: 'ASC' } },
|
order: { items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,11 +451,27 @@ export class ListTemplatesService {
|
|||||||
throw new NotFoundException('List template was not found.');
|
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.');
|
throw new ForbiddenException('List template belongs to another user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
template.items = template.items ?? [];
|
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;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,16 +648,74 @@ export class ListTemplatesService {
|
|||||||
return value;
|
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 {
|
return {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
ownerId: template.ownerId,
|
ownerId: template.ownerId,
|
||||||
|
ownerName: template.owner?.name ?? undefined,
|
||||||
|
ownerEmail: template.owner?.email ?? undefined,
|
||||||
|
accessRole: this.accessRoleFor(template, viewerId),
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description ?? undefined,
|
description: template.description ?? undefined,
|
||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
items: (template.items ?? [])
|
items: (template.items ?? [])
|
||||||
.sort((left, right) => left.position - right.position)
|
.sort((left, right) => left.position - right.position)
|
||||||
.map((item) => this.toListTemplateItem(item)),
|
.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),
|
createdAt: this.toIsoString(template.createdAt),
|
||||||
updatedAt: this.toIsoString(template.updatedAt),
|
updatedAt: this.toIsoString(template.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -259,8 +259,10 @@ describe('ListsService', () => {
|
|||||||
const template: ListTemplate = {
|
const template: ListTemplate = {
|
||||||
id: 'template-1',
|
id: 'template-1',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
|
accessRole: 'owner',
|
||||||
name: 'Urlaub',
|
name: 'Urlaub',
|
||||||
kind: 'packing',
|
kind: 'packing',
|
||||||
|
collaborators: [],
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'template-item-1',
|
id: 'template-item-1',
|
||||||
|
|||||||
@@ -135,10 +135,6 @@ export class ListsService {
|
|||||||
template: ListTemplate,
|
template: ListTemplate,
|
||||||
createDto: CreateListFromTemplateDto = {},
|
createDto: CreateListFromTemplateDto = {},
|
||||||
): Promise<UserList> {
|
): Promise<UserList> {
|
||||||
if (template.ownerId !== ownerId) {
|
|
||||||
throw new ForbiddenException('List template belongs to another user.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = this.listsRepository.create({
|
const list = this.listsRepository.create({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
ownerId,
|
ownerId,
|
||||||
@@ -876,6 +872,9 @@ export class ListsService {
|
|||||||
return {
|
return {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
ownerId: template.ownerId,
|
ownerId: template.ownerId,
|
||||||
|
ownerName: undefined,
|
||||||
|
ownerEmail: undefined,
|
||||||
|
accessRole: 'owner',
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description ?? undefined,
|
description: template.description ?? undefined,
|
||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
@@ -891,6 +890,7 @@ export class ListsService {
|
|||||||
createdAt: this.toIsoString(item.createdAt),
|
createdAt: this.toIsoString(item.createdAt),
|
||||||
updatedAt: this.toIsoString(item.updatedAt),
|
updatedAt: this.toIsoString(item.updatedAt),
|
||||||
})),
|
})),
|
||||||
|
collaborators: [],
|
||||||
createdAt: this.toIsoString(template.createdAt),
|
createdAt: this.toIsoString(template.createdAt),
|
||||||
updatedAt: this.toIsoString(template.updatedAt),
|
updatedAt: this.toIsoString(template.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<section class="template-detail-page">
|
<section class="template-detail-page">
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
<button mat-icon-button type="button" aria-label="Zurück" (click)="backToTemplates()">
|
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToTemplates()">
|
||||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}</h1>
|
<h1>{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}</h1>
|
||||||
<p>{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}</p>
|
<p>
|
||||||
|
{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}
|
||||||
|
@if (template()?.accessRole === 'collaborator') {
|
||||||
|
- geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@if (canEditItems()) {
|
@if (canEditItems()) {
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
@@ -23,13 +28,15 @@
|
|||||||
}
|
}
|
||||||
Als Liste
|
Als Liste
|
||||||
</button>
|
</button>
|
||||||
<button mat-icon-button type="button" aria-label="Template löschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
@if (canDeleteTemplate()) {
|
||||||
@if (deletingTemplate()) {
|
<button mat-icon-button type="button" aria-label="Template loeschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
@if (deletingTemplate()) {
|
||||||
} @else {
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
} @else {
|
||||||
}
|
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||||
</button>
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
@@ -86,17 +93,115 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
|
@if (template() && (canManageShares() || template()!.collaborators.length > 0 || template()!.accessRole === 'collaborator')) {
|
||||||
|
<mat-card class="editor-card" appearance="outlined">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>Freigaben</mat-card-title>
|
||||||
|
<mat-card-subtitle>
|
||||||
|
@if (canManageShares()) {
|
||||||
|
{{ template()!.collaborators.length }} Mitwirkende
|
||||||
|
} @else {
|
||||||
|
Geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }}
|
||||||
|
}
|
||||||
|
</mat-card-subtitle>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
@if (canManageShares()) {
|
||||||
|
<mat-form-field appearance="outline" class="share-search-field">
|
||||||
|
<mat-label>User suchen</mat-label>
|
||||||
|
<mat-icon matPrefix aria-hidden="true">person_search</mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="search"
|
||||||
|
[value]="shareSearchTerm()"
|
||||||
|
(input)="searchShareUsers($any($event.target).value)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
@if (searchingUsers()) {
|
||||||
|
<mat-progress-spinner matSuffix mode="indeterminate" diameter="18" />
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (availableShareSearchResults().length > 0) {
|
||||||
|
<ul class="share-results">
|
||||||
|
@for (user of availableShareSearchResults(); track user.id) {
|
||||||
|
<li>
|
||||||
|
<span>{{ displayUser(user) }}</span>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="sharingUserId() === user.id"
|
||||||
|
(click)="shareWithUser(user)"
|
||||||
|
>
|
||||||
|
@if (sharingUserId() === user.id) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">person_add</mat-icon>
|
||||||
|
}
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else if (shareSearchTerm().trim().length >= 2 && !searchingUsers()) {
|
||||||
|
<div class="inline-empty">
|
||||||
|
<mat-icon aria-hidden="true">person_off</mat-icon>
|
||||||
|
<span>Keine passenden User gefunden.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (template()!.collaborators.length > 0) {
|
||||||
|
<ul class="collaborator-list">
|
||||||
|
@for (collaborator of template()!.collaborators; track collaborator.id) {
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<strong>{{ collaborator.name || collaborator.email }}</strong>
|
||||||
|
@if (collaborator.name) {
|
||||||
|
<span>{{ collaborator.email }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (canManageShares()) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="displayUser(collaborator) + ' entfernen'"
|
||||||
|
[disabled]="removingShareUserId() === collaborator.id"
|
||||||
|
(click)="removeCollaborator(collaborator.id)"
|
||||||
|
>
|
||||||
|
@if (removingShareUserId() === collaborator.id) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">person_remove</mat-icon>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<div class="inline-empty">
|
||||||
|
<mat-icon aria-hidden="true">group</mat-icon>
|
||||||
|
<span>Noch keine Mitwirkenden.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-item">
|
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-item">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>Items</mat-card-title>
|
<mat-card-title>Items</mat-card-title>
|
||||||
<mat-card-subtitle>
|
<mat-card-subtitle>
|
||||||
@if (canEditItems()) {
|
@if (canEditItems()) {
|
||||||
{{ template()?.items?.length || 0 }} Einträge
|
{{ template()?.items?.length || 0 }} Eintraege
|
||||||
@if (reordering()) {
|
@if (reordering()) {
|
||||||
- Reihenfolge wird gespeichert
|
- Reihenfolge wird gespeichert
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
Nach dem Speichern verfügbar
|
Nach dem Speichern verfuegbar
|
||||||
}
|
}
|
||||||
</mat-card-subtitle>
|
</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@@ -119,14 +224,14 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<mat-icon aria-hidden="true">add</mat-icon>
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
}
|
}
|
||||||
Hinzufügen
|
Hinzufuegen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if (!canEditItems()) {
|
@if (!canEditItems()) {
|
||||||
<div class="inline-empty">
|
<div class="inline-empty">
|
||||||
<mat-icon aria-hidden="true">save</mat-icon>
|
<mat-icon aria-hidden="true">save</mat-icon>
|
||||||
<span>Speichere das Template, bevor du Items hinzufügst.</span>
|
<span>Speichere das Template, bevor du Items hinzufuegst.</span>
|
||||||
</div>
|
</div>
|
||||||
} @else if (template()?.items?.length) {
|
} @else if (template()?.items?.length) {
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
@@ -76,6 +76,56 @@
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.state-card mat-card-content {
|
.state-card mat-card-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { AuthService } from '../../auth/auth.service';
|
||||||
|
import { PublicUserSearchResult } from '../../auth/auth.models';
|
||||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||||
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
||||||
@@ -44,6 +46,7 @@ import { TemplatesService } from '../templates.service';
|
|||||||
export class TemplateDetailComponent implements OnInit {
|
export class TemplateDetailComponent implements OnInit {
|
||||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
|
private readonly authService = inject(AuthService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
@@ -60,6 +63,11 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
protected readonly copyingTemplate = signal(false);
|
protected readonly copyingTemplate = signal(false);
|
||||||
protected readonly reordering = signal(false);
|
protected readonly reordering = signal(false);
|
||||||
protected readonly errorMessage = signal<string | null>(null);
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly shareSearchTerm = signal('');
|
||||||
|
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
|
||||||
|
protected readonly searchingUsers = signal(false);
|
||||||
|
protected readonly sharingUserId = signal<string | null>(null);
|
||||||
|
protected readonly removingShareUserId = signal<string | null>(null);
|
||||||
|
|
||||||
protected readonly templateForm = this.formBuilder.group({
|
protected readonly templateForm = this.formBuilder.group({
|
||||||
name: ['', [Validators.required]],
|
name: ['', [Validators.required]],
|
||||||
@@ -71,6 +79,22 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
required: [true],
|
required: [true],
|
||||||
});
|
});
|
||||||
protected readonly canEditItems = computed(() => Boolean(this.template()?.id));
|
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 {
|
ngOnInit(): void {
|
||||||
this.isCreateMode.set(this.templateId() === null);
|
this.isCreateMode.set(this.templateId() === null);
|
||||||
@@ -292,11 +316,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
const templateId = this.templateId();
|
const templateId = this.templateId();
|
||||||
const template = this.template();
|
const template = this.template();
|
||||||
|
|
||||||
if (
|
if (!templateId || !template || !this.canDeleteTemplate() || this.deletingTemplate()) {
|
||||||
!templateId ||
|
|
||||||
!template ||
|
|
||||||
this.deletingTemplate()
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +358,77 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
await this.router.navigateByUrl('/templates');
|
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 {
|
private setTemplate(template: ListTemplate): void {
|
||||||
this.template.set(template);
|
this.template.set(template);
|
||||||
this.templateForm.reset({
|
this.templateForm.reset({
|
||||||
|
|||||||
@@ -41,7 +41,12 @@
|
|||||||
<mat-card class="template-card" appearance="outlined">
|
<mat-card class="template-card" appearance="outlined">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ template.name }}</mat-card-title>
|
<mat-card-title>{{ template.name }}</mat-card-title>
|
||||||
<mat-card-subtitle>{{ kindLabel(template.kind) }}</mat-card-subtitle>
|
<mat-card-subtitle>
|
||||||
|
{{ kindLabel(template.kind) }}
|
||||||
|
@if (template.accessRole === 'collaborator') {
|
||||||
|
- geteilt von {{ template.ownerName || template.ownerEmail || 'Owner' }}
|
||||||
|
}
|
||||||
|
</mat-card-subtitle>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
@@ -58,6 +63,12 @@
|
|||||||
<mat-icon aria-hidden="true">schedule</mat-icon>
|
<mat-icon aria-hidden="true">schedule</mat-icon>
|
||||||
{{ template.updatedAt | date: 'dd.MM.yyyy' }}
|
{{ template.updatedAt | date: 'dd.MM.yyyy' }}
|
||||||
</span>
|
</span>
|
||||||
|
@if (template.collaborators.length > 0) {
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">group</mat-icon>
|
||||||
|
{{ template.collaborators.length }} Mitwirkende
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (template.items.length > 0) {
|
@if (template.items.length > 0) {
|
||||||
@@ -92,6 +103,7 @@
|
|||||||
<mat-icon aria-hidden="true">edit</mat-icon>
|
<mat-icon aria-hidden="true">edit</mat-icon>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</a>
|
</a>
|
||||||
|
@if (template.accessRole === 'owner') {
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,6 +117,7 @@
|
|||||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</mat-card-actions>
|
</mat-card-actions>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,27 @@ export interface ListTemplateItem {
|
|||||||
export interface ListTemplate {
|
export interface ListTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
ownerEmail?: string;
|
||||||
|
accessRole: ListTemplateAccessRole;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
items: ListTemplateItem[];
|
items: ListTemplateItem[];
|
||||||
|
collaborators: ListTemplateCollaborator[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListTemplateAccessRole = 'owner' | 'collaborator';
|
||||||
|
|
||||||
|
export interface ListTemplateCollaborator {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
role: 'collaborator';
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserListItem {
|
export interface UserListItem {
|
||||||
id: string;
|
id: string;
|
||||||
sourceTemplateItemId?: string;
|
sourceTemplateItemId?: string;
|
||||||
@@ -38,12 +51,21 @@ export interface UserListItem {
|
|||||||
export interface UserList {
|
export interface UserList {
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
ownerEmail?: string;
|
||||||
|
accessRole?: 'owner' | 'collaborator';
|
||||||
sourceTemplateId?: string;
|
sourceTemplateId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
reminderAt?: string | null;
|
reminderAt?: string | null;
|
||||||
items: UserListItem[];
|
items: UserListItem[];
|
||||||
|
collaborators?: Array<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
role: 'collaborator';
|
||||||
|
}>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export class TemplatesService {
|
|||||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`);
|
return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shareTemplate(templateId: string, userId: string): Observable<ListTemplate> {
|
||||||
|
return this.http.post<ListTemplate>(`${this.apiUrl}/${templateId}/shares`, {
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeShare(templateId: string, userId: string): Observable<ListTemplate> {
|
||||||
|
return this.http.delete<ListTemplate>(
|
||||||
|
`${this.apiUrl}/${templateId}/shares/${userId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
createListFromTemplate(
|
createListFromTemplate(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
data: CreateListFromTemplateRequest = {},
|
data: CreateListFromTemplateRequest = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user