From 16605c961f4eb7d41513098e55981177642362a1 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 9 Jun 2026 16:03:20 +0200 Subject: [PATCH] name beim kopieren --- listify-api/src/app.module.ts | 2 + listify-api/src/audit/audit-log.entity.ts | 42 +++++++ listify-api/src/audit/audit-log.service.ts | 70 +++++++++++ listify-api/src/audit/audit-log.types.ts | 35 ++++++ listify-api/src/audit/audit.module.ts | 11 ++ listify-api/src/auth/auth.module.ts | 7 +- listify-api/src/auth/auth.service.ts | 89 +++++++++++-- listify-api/src/database/data-source.ts | 2 + .../1781200000000-CreateAuditLogs.ts | 37 ++++++ .../list-templates/list-templates.module.ts | 2 + .../list-templates/list-templates.service.ts | 103 ++++++++++++++- listify-api/src/lists/lists.module.ts | 7 +- listify-api/src/lists/lists.service.ts | 117 +++++++++++++++++- .../copy-template-dialog.component.html | 30 +++++ .../copy-template-dialog.component.scss | 14 +++ .../copy-template-dialog.component.ts | 60 +++++++++ .../template-detail.component.ts | 55 +++++--- .../src/app/templates/templates.component.ts | 54 +++++--- 18 files changed, 693 insertions(+), 44 deletions(-) create mode 100644 listify-api/src/audit/audit-log.entity.ts create mode 100644 listify-api/src/audit/audit-log.service.ts create mode 100644 listify-api/src/audit/audit-log.types.ts create mode 100644 listify-api/src/audit/audit.module.ts create mode 100644 listify-api/src/database/migrations/1781200000000-CreateAuditLogs.ts create mode 100644 listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.html create mode 100644 listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.scss create mode 100644 listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.ts diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 251e297..91d38b6 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -4,6 +4,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AuditModule } from './audit/audit.module'; import { AuthModule } from './auth/auth.module'; import { ListTemplatesModule } from './list-templates/list-templates.module'; import { ListsModule } from './lists/lists.module'; @@ -50,6 +51,7 @@ import { DatabaseLogger } from './database/database.logger'; }, }), EventEmitterModule.forRoot(), + AuditModule, AuthModule, MailModule, ListsModule, diff --git a/listify-api/src/audit/audit-log.entity.ts b/listify-api/src/audit/audit-log.entity.ts new file mode 100644 index 0000000..64fd559 --- /dev/null +++ b/listify-api/src/audit/audit-log.entity.ts @@ -0,0 +1,42 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity('audit_logs') +export class AuditLogEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index('IDX_audit_logs_actor_user_id') + @Column({ type: 'varchar', length: 36, nullable: true }) + actorUserId?: string | null; + + @Column({ type: 'varchar', length: 320, nullable: true }) + actorEmail?: string | null; + + @Index('IDX_audit_logs_action') + @Column({ type: 'varchar', length: 100 }) + action!: string; + + @Index('IDX_audit_logs_entity') + @Column({ type: 'varchar', length: 80, nullable: true }) + entityType?: string | null; + + @Column({ type: 'varchar', length: 36, nullable: true }) + entityId?: string | null; + + @Column({ type: 'json', nullable: true }) + metadata?: Record | null; + + @Column({ type: 'varchar', length: 64, nullable: true }) + ipAddress?: string | null; + + @Column({ type: 'varchar', length: 512, nullable: true }) + userAgent?: string | null; + + @Index('IDX_audit_logs_created_at') + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; +} diff --git a/listify-api/src/audit/audit-log.service.ts b/listify-api/src/audit/audit-log.service.ts new file mode 100644 index 0000000..c8e738d --- /dev/null +++ b/listify-api/src/audit/audit-log.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { Repository } from 'typeorm'; +import { AuditLogEntity } from './audit-log.entity'; +import type { AuditLogInput } from './audit-log.types'; + +const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i; + +@Injectable() +export class AuditLogService { + private readonly logger = new Logger(AuditLogService.name); + + constructor( + @InjectRepository(AuditLogEntity) + private readonly auditLogsRepository: Repository, + ) {} + + async record(input: AuditLogInput): Promise { + try { + await this.auditLogsRepository.save( + this.auditLogsRepository.create({ + id: randomUUID(), + actorUserId: input.actorUserId ?? null, + actorEmail: input.actorEmail ?? null, + action: input.action, + entityType: input.entityType ?? null, + entityId: input.entityId ?? null, + metadata: input.metadata ? this.sanitizeValue(input.metadata) : null, + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ?? null, + }), + ); + } catch (error) { + this.logger.error( + `Audit log could not be stored for action ${input.action}.`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + private sanitizeValue(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { value: this.sanitizeNestedValue(value) }; + } + + return this.sanitizeNestedValue(value) as Record; + } + + private sanitizeNestedValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => this.sanitizeNestedValue(entry)); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry), + ]), + ); + } + + return value; + } +} diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts new file mode 100644 index 0000000..837ef7e --- /dev/null +++ b/listify-api/src/audit/audit-log.types.ts @@ -0,0 +1,35 @@ +export type AuditAction = + | 'user.registered' + | 'user.email_verified' + | 'user.verification_resent' + | 'user.login_succeeded' + | 'user.login_failed' + | 'user.token_refreshed' + | 'user.onboarding_updated' + | 'template.created' + | 'template.updated' + | 'template.deleted' + | 'template.item_created' + | 'template.item_updated' + | 'template.item_deleted' + | 'template.items_reordered' + | 'list.created' + | 'list.created_from_template' + | 'list.updated' + | 'list.deleted' + | 'list.item_created' + | 'list.item_updated' + | 'list.item_checked' + | 'list.item_unchecked' + | 'list.item_deleted'; + +export interface AuditLogInput { + actorUserId?: string | null; + actorEmail?: string | null; + action: AuditAction; + entityType?: string | null; + entityId?: string | null; + metadata?: Record | null; + ipAddress?: string | null; + userAgent?: string | null; +} diff --git a/listify-api/src/audit/audit.module.ts b/listify-api/src/audit/audit.module.ts new file mode 100644 index 0000000..f785f3a --- /dev/null +++ b/listify-api/src/audit/audit.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLogEntity } from './audit-log.entity'; +import { AuditLogService } from './audit-log.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLogEntity])], + providers: [AuditLogService], + exports: [AuditLogService], +}) +export class AuditModule {} diff --git a/listify-api/src/auth/auth.module.ts b/listify-api/src/auth/auth.module.ts index 948597d..02bdbd1 100644 --- a/listify-api/src/auth/auth.module.ts +++ b/listify-api/src/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from '../audit/audit.module'; import { AuthController } from './auth.controller'; import { RefreshTokenEntity } from './refresh-token.entity'; import { AuthService } from './auth.service'; @@ -8,7 +9,11 @@ import { JwtAuthGuard } from './jwt-auth.guard'; import { UserEntity } from './user.entity'; @Module({ - imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity])], + imports: [ + AuditModule, + JwtModule.register({}), + TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), + ], controllers: [AuthController], providers: [AuthService, JwtAuthGuard], exports: [AuthService, JwtAuthGuard], diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index 6e6c511..c0f7986 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable, + Optional, UnauthorizedException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -9,6 +10,7 @@ import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto'; import { Repository } from 'typeorm'; +import { AuditLogService } from '../audit/audit-log.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -39,6 +41,8 @@ export class AuthService { private readonly usersRepository: Repository, @InjectRepository(RefreshTokenEntity) private readonly refreshTokensRepository: Repository, + @Optional() + private readonly auditLogService?: AuditLogService, ) {} async register( @@ -67,6 +71,15 @@ export class AuthService { }); const savedUser = await this.usersRepository.save(user); + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.registered', + entityType: 'user', + entityId: savedUser.id, + metadata: { verified: savedUser.verified }, + }); + this.eventEmitter.emit(AppEvents.UserRegistered, { email, verificationUrl: this.createVerificationUrl(verificationToken), @@ -98,10 +111,18 @@ export class AuthService { try { const savedUser = await this.usersRepository.save(user); - return { - message: 'Email verified successfully.', - user: this.toPublicUser(savedUser), - }; + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.email_verified', + entityType: 'user', + entityId: savedUser.id, + }); + + return { + message: 'Email verified successfully.', + user: this.toPublicUser(savedUser), + }; } catch { throw new BadRequestException('user not saved.') } @@ -122,6 +143,14 @@ export class AuthService { user.verificationToken = this.createToken(); const savedUser = await this.usersRepository.save(user); + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.verification_resent', + entityType: 'user', + entityId: savedUser.id, + }); + this.eventEmitter.emit(AppEvents.UserRegistered, { email: savedUser.email, verificationUrl: this.createVerificationUrl(savedUser.verificationToken!), @@ -136,17 +165,42 @@ export class AuthService { const user = await this.usersRepository.findOne({ where: { email } }); if (!user || !this.passwordMatches(password, user.passwordHash)) { + await this.auditLogService?.record({ + actorEmail: email, + action: 'user.login_failed', + entityType: 'user', + entityId: user?.id, + metadata: { reason: 'invalid_credentials' }, + }); throw new UnauthorizedException('Invalid email or password.'); } if (!user.verified) { + await this.auditLogService?.record({ + actorUserId: user.id, + actorEmail: user.email, + action: 'user.login_failed', + entityType: 'user', + entityId: user.id, + metadata: { reason: 'email_not_verified' }, + }); throw new UnauthorizedException('Please verify your email before login.'); } - return { + const response = { ...(await this.createAuthTokens(user)), user: this.toPublicUser(user), }; + + await this.auditLogService?.record({ + actorUserId: user.id, + actorEmail: user.email, + action: 'user.login_succeeded', + entityType: 'user', + entityId: user.id, + }); + + return response; } async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise { @@ -175,10 +229,20 @@ export class AuthService { await this.refreshTokensRepository.delete({ jti: payload.jti }); - return { + const response = { ...(await this.createAuthTokens(user)), user: this.toPublicUser(user), }; + + await this.auditLogService?.record({ + actorUserId: user.id, + actorEmail: user.email, + action: 'user.token_refreshed', + entityType: 'user', + entityId: user.id, + }); + + return response; } async verifyAccessToken(accessToken: string): Promise { @@ -243,7 +307,18 @@ export class AuthService { user.onboardingCompleted = completed; - return this.toPublicUser(await this.usersRepository.save(user)); + const savedUser = await this.usersRepository.save(user); + + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.onboarding_updated', + entityType: 'user', + entityId: savedUser.id, + metadata: { completed }, + }); + + return this.toPublicUser(savedUser); } private normalizeEmail(email?: string): string { diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 620ee22..0fa0ef6 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import 'reflect-metadata'; import { DataSource } from 'typeorm'; +import { AuditLogEntity } from '../audit/audit-log.entity'; import { UserEntity } from '../auth/user.entity'; import { RefreshTokenEntity } from '../auth/refresh-token.entity'; import { ListTemplateEntity } from '../list-templates/list-template.entity'; @@ -27,6 +28,7 @@ export default new DataSource({ logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)), maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env), entities: [ + AuditLogEntity, UserEntity, RefreshTokenEntity, ListTemplateEntity, diff --git a/listify-api/src/database/migrations/1781200000000-CreateAuditLogs.ts b/listify-api/src/database/migrations/1781200000000-CreateAuditLogs.ts new file mode 100644 index 0000000..29070ff --- /dev/null +++ b/listify-api/src/database/migrations/1781200000000-CreateAuditLogs.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAuditLogs1781200000000 implements MigrationInterface { + name = 'CreateAuditLogs1781200000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('audit_logs')) { + return; + } + + await queryRunner.query(` + CREATE TABLE \`audit_logs\` ( + \`id\` varchar(36) NOT NULL, + \`actorUserId\` varchar(36) NULL, + \`actorEmail\` varchar(320) NULL, + \`action\` varchar(100) NOT NULL, + \`entityType\` varchar(80) NULL, + \`entityId\` varchar(36) NULL, + \`metadata\` json NULL, + \`ipAddress\` varchar(64) NULL, + \`userAgent\` varchar(512) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX \`IDX_audit_logs_actor_user_id\` (\`actorUserId\`), + INDEX \`IDX_audit_logs_action\` (\`action\`), + INDEX \`IDX_audit_logs_entity\` (\`entityType\`), + INDEX \`IDX_audit_logs_created_at\` (\`createdAt\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('audit_logs')) { + await queryRunner.query('DROP TABLE `audit_logs`'); + } + } +} diff --git a/listify-api/src/list-templates/list-templates.module.ts b/listify-api/src/list-templates/list-templates.module.ts index 3b07a4e..2faa67b 100644 --- a/listify-api/src/list-templates/list-templates.module.ts +++ b/listify-api/src/list-templates/list-templates.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { ListsModule } from '../lists/lists.module'; import { ListTemplatesController } from './list-templates.controller'; @@ -10,6 +11,7 @@ import { TemplateSeedEntity } from './template-seed.entity'; @Module({ imports: [ + AuditModule, AuthModule, ListsModule, TypeOrmModule.forFeature([ diff --git a/listify-api/src/list-templates/list-templates.service.ts b/listify-api/src/list-templates/list-templates.service.ts index a22a094..ef715bb 100644 --- a/listify-api/src/list-templates/list-templates.service.ts +++ b/listify-api/src/list-templates/list-templates.service.ts @@ -3,10 +3,12 @@ import { ForbiddenException, Injectable, NotFoundException, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; +import { AuditLogService } from '../audit/audit-log.service'; import { AddListTemplateItemDto, ReorderListTemplateItemsDto, @@ -35,6 +37,8 @@ export class ListTemplatesService { private readonly templateItemsRepository: Repository, @InjectRepository(TemplateSeedEntity) private readonly templateSeedsRepository: Repository, + @Optional() + private readonly auditLogService?: AuditLogService, ) {} async createTemplate( @@ -50,7 +54,21 @@ export class ListTemplatesService { items: this.createTemplateItems(createDto.items ?? []), }); - return this.toListTemplate(await this.templatesRepository.save(template)); + const savedTemplate = await this.templatesRepository.save(template); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.created', + entityType: 'template', + entityId: savedTemplate.id, + metadata: { + name: savedTemplate.name, + kind: savedTemplate.kind, + itemCount: savedTemplate.items?.length ?? 0, + }, + }); + + return this.toListTemplate(savedTemplate); } async listTemplates(ownerId: string): Promise { @@ -99,7 +117,25 @@ export class ListTemplatesService { }); } - return this.toListTemplate(await this.templatesRepository.save(template)); + const savedTemplate = await this.templatesRepository.save(template); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.updated', + entityType: 'template', + entityId: savedTemplate.id, + metadata: { + changedFields: Object.keys(updateDto).filter( + (field) => + updateDto[field as keyof UpdateListTemplateDto] !== undefined, + ), + name: savedTemplate.name, + kind: savedTemplate.kind, + itemCount: savedTemplate.items?.length ?? 0, + }, + }); + + return this.toListTemplate(savedTemplate); } async deleteTemplate( @@ -107,8 +143,21 @@ export class ListTemplatesService { templateId: string, ): Promise<{ message: string }> { const template = await this.findOwnedTemplate(ownerId, templateId); + const metadata = { + name: template.name, + kind: template.kind, + itemCount: template.items.length, + }; await this.templatesRepository.remove(template); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.deleted', + entityType: 'template', + entityId: templateId, + metadata, + }); + return { message: 'List template deleted.' }; } @@ -125,6 +174,19 @@ export class ListTemplatesService { template.items.push(savedItem); await this.templatesRepository.save(template); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.item_created', + entityType: 'template_item', + entityId: savedItem.id, + metadata: { + templateId, + title: savedItem.title, + required: savedItem.required, + position: savedItem.position, + }, + }); + return this.getTemplate(ownerId, templateId); } @@ -156,6 +218,21 @@ export class ListTemplatesService { await this.templateItemsRepository.save(item); await this.templatesRepository.save(template); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.item_updated', + entityType: 'template_item', + entityId: item.id, + metadata: { + templateId, + title: item.title, + changedFields: Object.keys(updateDto).filter( + (field) => + updateDto[field as keyof UpdateListTemplateItemDto] !== undefined, + ), + }, + }); + return this.getTemplate(ownerId, templateId); } @@ -195,6 +272,16 @@ export class ListTemplatesService { await this.templateItemsRepository.save(reorderedItems); await this.templatesRepository.save(template); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.items_reordered', + entityType: 'template', + entityId: templateId, + metadata: { + itemIds, + }, + }); + return this.getTemplate(ownerId, templateId); } @@ -219,6 +306,18 @@ export class ListTemplatesService { await this.templateItemsRepository.save(template.items); await this.templatesRepository.save(template); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'template.item_deleted', + entityType: 'template_item', + entityId: itemId, + metadata: { + templateId, + title: itemToDelete.title, + position: itemToDelete.position, + }, + }); + return this.getTemplate(ownerId, templateId); } diff --git a/listify-api/src/lists/lists.module.ts b/listify-api/src/lists/lists.module.ts index 326d831..3f96e2f 100644 --- a/listify-api/src/lists/lists.module.ts +++ b/listify-api/src/lists/lists.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { ListsController } from './lists.controller'; import { ListsService } from './lists.service'; @@ -7,7 +8,11 @@ import { UserListEntity } from './user-list.entity'; import { UserListItemEntity } from './user-list-item.entity'; @Module({ - imports: [AuthModule, TypeOrmModule.forFeature([UserListEntity, UserListItemEntity])], + imports: [ + AuditModule, + AuthModule, + TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]), + ], controllers: [ListsController], providers: [ListsService], exports: [ListsService], diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 1987cfe..55a0a9c 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -3,10 +3,12 @@ import { ForbiddenException, Injectable, NotFoundException, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; +import { AuditLogService } from '../audit/audit-log.service'; import { ListTemplate, ListTemplateKind, @@ -27,6 +29,8 @@ export class ListsService { private readonly listsRepository: Repository, @InjectRepository(UserListItemEntity) private readonly listItemsRepository: Repository, + @Optional() + private readonly auditLogService?: AuditLogService, ) {} async createList(ownerId: string, createDto: CreateListDto): Promise { @@ -39,7 +43,20 @@ export class ListsService { items: [], }); - return this.toUserList(await this.listsRepository.save(list)); + const savedList = await this.listsRepository.save(list); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.created', + entityType: 'list', + entityId: savedList.id, + metadata: { + name: savedList.name, + kind: savedList.kind, + }, + }); + + return this.toUserList(savedList); } async createListFromTemplate( @@ -75,7 +92,23 @@ export class ListsService { ), }); - return this.toUserList(await this.listsRepository.save(list)); + const savedList = await this.listsRepository.save(list); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.created_from_template', + entityType: 'list', + entityId: savedList.id, + metadata: { + name: savedList.name, + kind: savedList.kind, + sourceTemplateId: template.id, + sourceTemplateName: template.name, + itemCount: savedList.items?.length ?? 0, + }, + }); + + return this.toUserList(savedList); } async listLists(ownerId: string): Promise { @@ -111,13 +144,42 @@ export class ListsService { list.kind = this.normalizeKind(updateDto.kind); } - return this.toUserList(await this.listsRepository.save(list)); + const savedList = await this.listsRepository.save(list); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.updated', + entityType: 'list', + entityId: savedList.id, + metadata: { + changedFields: Object.keys(updateDto).filter( + (field) => updateDto[field as keyof UpdateListDto] !== undefined, + ), + name: savedList.name, + kind: savedList.kind, + }, + }); + + return this.toUserList(savedList); } async deleteList(ownerId: string, listId: string): Promise<{ message: string }> { const list = await this.findOwnedList(ownerId, listId); + const metadata = { + name: list.name, + kind: list.kind, + itemCount: list.items.length, + }; await this.listsRepository.remove(list); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.deleted', + entityType: 'list', + entityId: listId, + metadata, + }); + return { message: 'List deleted.' }; } @@ -134,6 +196,19 @@ export class ListsService { list.items.push(savedItem); await this.listsRepository.save(list); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.item_created', + entityType: 'list_item', + entityId: savedItem.id, + metadata: { + listId, + title: savedItem.title, + required: savedItem.required, + position: savedItem.position, + }, + }); + return this.getList(ownerId, listId); } @@ -146,6 +221,7 @@ export class ListsService { ): Promise { const list = await this.findOwnedList(ownerId, listId); const item = this.findListItem(list, itemId); + const wasChecked = item.checked; if (updateDto.title !== undefined) { item.title = this.requireItemTitle(updateDto.title); @@ -180,6 +256,29 @@ export class ListsService { await this.listItemsRepository.save(item); await this.listsRepository.save(list); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: + updateDto.checked === true + ? 'list.item_checked' + : updateDto.checked === false + ? 'list.item_unchecked' + : 'list.item_updated', + entityType: 'list_item', + entityId: item.id, + metadata: { + listId, + title: item.title, + changedFields: Object.keys(updateDto).filter( + (field) => updateDto[field as keyof UpdateListItemDto] !== undefined, + ), + previousChecked: wasChecked, + checked: item.checked, + checkedAt: item.checkedAt, + checkedByName: item.checkedByName, + }, + }); + return this.getList(ownerId, listId); } @@ -204,6 +303,18 @@ export class ListsService { await this.listItemsRepository.save(list.items); await this.listsRepository.save(list); + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'list.item_deleted', + entityType: 'list_item', + entityId: itemId, + metadata: { + listId, + title: itemToDelete.title, + position: itemToDelete.position, + }, + }); + return this.getList(ownerId, listId); } diff --git a/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.html b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.html new file mode 100644 index 0000000..3de1ff1 --- /dev/null +++ b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.html @@ -0,0 +1,30 @@ +

Liste aus Template erstellen

+ +
+ +

+ Vergib einen individuellen Namen für die neue Liste. +

+ + + Listenname + + @if (form.controls.name.hasError('required')) { + Listenname ist erforderlich. + } + + + + Beschreibung + + +
+ + + + + +
diff --git a/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.scss b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.scss new file mode 100644 index 0000000..4511ed2 --- /dev/null +++ b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.scss @@ -0,0 +1,14 @@ +.dialog-copy { + margin: 0 0 1rem; + color: var(--mat-sys-on-surface-variant); +} + +mat-form-field { + width: 100%; +} + +mat-dialog-content { + display: grid; + gap: 0.75rem; + min-width: min(100vw - 64px, 360px); +} diff --git a/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.ts b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.ts new file mode 100644 index 0000000..261d52d --- /dev/null +++ b/listify-client/src/app/templates/copy-template-dialog/copy-template-dialog.component.ts @@ -0,0 +1,60 @@ +import { Component, inject } from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; + +export interface CopyTemplateDialogData { + templateName: string; + templateDescription?: string; +} + +export interface CopyTemplateDialogResult { + name: string; + description?: string; +} + +@Component({ + selector: 'app-copy-template-dialog', + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + ], + templateUrl: './copy-template-dialog.component.html', + styleUrl: './copy-template-dialog.component.scss', +}) +export class CopyTemplateDialogComponent { + protected readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject( + MatDialogRef, + ); + private readonly formBuilder = inject(NonNullableFormBuilder); + + protected readonly form = this.formBuilder.group({ + name: [this.data.templateName, [Validators.required]], + description: [this.data.templateDescription ?? ''], + }); + + protected submit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const value = this.form.getRawValue(); + this.dialogRef.close({ + name: value.name.trim(), + description: value.description.trim() || undefined, + }); + } +} diff --git a/listify-client/src/app/templates/template-detail/template-detail.component.ts b/listify-client/src/app/templates/template-detail/template-detail.component.ts index 1d1aee6..63bc535 100644 --- a/listify-client/src/app/templates/template-detail/template-detail.component.ts +++ b/listify-client/src/app/templates/template-detail/template-detail.component.ts @@ -15,6 +15,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { getAuthErrorMessage } from '../../auth/error-message'; import { OnboardingService } from '../../onboarding/onboarding.service'; import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component'; +import { + CopyTemplateDialogComponent, + CopyTemplateDialogResult, +} from '../copy-template-dialog/copy-template-dialog.component'; import { ListTemplate } from '../templates.models'; import { TemplatesService } from '../templates.service'; @@ -239,27 +243,48 @@ export class TemplateDetailComponent implements OnInit { protected copyTemplateToList(): void { const templateId = this.templateId(); + const template = this.template(); - if (!templateId || !this.canEditItems() || this.copyingTemplate()) { + if (!templateId || !template || !this.canEditItems() || this.copyingTemplate()) { return; } - this.copyingTemplate.set(true); + this.dialog + .open< + CopyTemplateDialogComponent, + { templateName: string; templateDescription?: string }, + CopyTemplateDialogResult + >(CopyTemplateDialogComponent, { + data: { + templateName: template.name, + templateDescription: template.description, + }, + maxWidth: '480px', + width: 'calc(100vw - 32px)', + }) + .afterClosed() + .subscribe((result) => { + if (!result) { + return; + } - this.templatesService - .createListFromTemplate(templateId) - .pipe(finalize(() => this.copyingTemplate.set(false))) - .subscribe({ - next: (list) => { - this.snackBar.open('Liste aus Template erstellt.', 'OK', { - duration: 3000, + this.copyingTemplate.set(true); + + this.templatesService + .createListFromTemplate(templateId, result) + .pipe(finalize(() => this.copyingTemplate.set(false))) + .subscribe({ + next: (list) => { + this.snackBar.open('Liste aus Template erstellt.', 'OK', { + duration: 3000, + }); + this.onboarding.templateCopiedToList(list.id); + void this.router.navigateByUrl('/lists'); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, }); - this.onboarding.templateCopiedToList(list.id); - void this.router.navigateByUrl('/lists'); - }, - error: (error: unknown) => { - this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); - }, }); } diff --git a/listify-client/src/app/templates/templates.component.ts b/listify-client/src/app/templates/templates.component.ts index 9c25e52..058535d 100644 --- a/listify-client/src/app/templates/templates.component.ts +++ b/listify-client/src/app/templates/templates.component.ts @@ -11,6 +11,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { getAuthErrorMessage } from '../auth/error-message'; import { OnboardingService } from '../onboarding/onboarding.service'; import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component'; +import { + CopyTemplateDialogComponent, + CopyTemplateDialogResult, +} from './copy-template-dialog/copy-template-dialog.component'; import { ListTemplate, ListTemplateKind } from './templates.models'; import { TemplatesService } from './templates.service'; @@ -79,23 +83,43 @@ export class TemplatesComponent implements OnInit { return; } - this.copyingTemplateId.set(template.id); + this.dialog + .open< + CopyTemplateDialogComponent, + { templateName: string; templateDescription?: string }, + CopyTemplateDialogResult + >(CopyTemplateDialogComponent, { + data: { + templateName: template.name, + templateDescription: template.description, + }, + maxWidth: '480px', + width: 'calc(100vw - 32px)', + }) + .afterClosed() + .subscribe((result) => { + if (!result) { + return; + } - this.templatesService - .createListFromTemplate(template.id) - .pipe(finalize(() => this.copyingTemplateId.set(null))) - .subscribe({ - next: () => { - this.snackBar.open('Liste aus Template erstellt.', 'OK', { - duration: 3000, + this.copyingTemplateId.set(template.id); + + this.templatesService + .createListFromTemplate(template.id, result) + .pipe(finalize(() => this.copyingTemplateId.set(null))) + .subscribe({ + next: () => { + this.snackBar.open('Liste aus Template erstellt.', 'OK', { + duration: 3000, + }); + void this.router.navigateByUrl('/lists'); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, }); - void this.router.navigateByUrl('/lists'); - }, - error: (error: unknown) => { - this.snackBar.open(getAuthErrorMessage(error), 'OK', { - duration: 5000, - }); - }, }); }