name beim kopieren
This commit is contained in:
@@ -4,6 +4,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||||
import { ListsModule } from './lists/lists.module';
|
import { ListsModule } from './lists/lists.module';
|
||||||
@@ -50,6 +51,7 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
ListsModule,
|
ListsModule,
|
||||||
|
|||||||
42
listify-api/src/audit/audit-log.entity.ts
Normal file
42
listify-api/src/audit/audit-log.entity.ts
Normal file
@@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
70
listify-api/src/audit/audit-log.service.ts
Normal file
70
listify-api/src/audit/audit-log.service.ts
Normal file
@@ -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<AuditLogEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async record(input: AuditLogInput): Promise<void> {
|
||||||
|
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<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { value: this.sanitizeNestedValue(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sanitizeNestedValue(value) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>).map(([key, entry]) => [
|
||||||
|
key,
|
||||||
|
SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : this.sanitizeNestedValue(entry),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
listify-api/src/audit/audit-log.types.ts
Normal file
35
listify-api/src/audit/audit-log.types.ts
Normal file
@@ -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<string, unknown> | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
}
|
||||||
11
listify-api/src/audit/audit.module.ts
Normal file
11
listify-api/src/audit/audit.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -8,7 +9,11 @@ import { JwtAuthGuard } from './jwt-auth.guard';
|
|||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity])],
|
imports: [
|
||||||
|
AuditModule,
|
||||||
|
JwtModule.register({}),
|
||||||
|
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
|
||||||
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtAuthGuard],
|
providers: [AuthService, JwtAuthGuard],
|
||||||
exports: [AuthService, JwtAuthGuard],
|
exports: [AuthService, JwtAuthGuard],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Optional,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
@@ -9,6 +10,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
@@ -39,6 +41,8 @@ export class AuthService {
|
|||||||
private readonly usersRepository: Repository<UserEntity>,
|
private readonly usersRepository: Repository<UserEntity>,
|
||||||
@InjectRepository(RefreshTokenEntity)
|
@InjectRepository(RefreshTokenEntity)
|
||||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||||
|
@Optional()
|
||||||
|
private readonly auditLogService?: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(
|
async register(
|
||||||
@@ -67,6 +71,15 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
const savedUser = await this.usersRepository.save(user);
|
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, {
|
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||||
email,
|
email,
|
||||||
verificationUrl: this.createVerificationUrl(verificationToken),
|
verificationUrl: this.createVerificationUrl(verificationToken),
|
||||||
@@ -98,6 +111,14 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const savedUser = await this.usersRepository.save(user);
|
const savedUser = await this.usersRepository.save(user);
|
||||||
|
|
||||||
|
await this.auditLogService?.record({
|
||||||
|
actorUserId: savedUser.id,
|
||||||
|
actorEmail: savedUser.email,
|
||||||
|
action: 'user.email_verified',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: savedUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Email verified successfully.',
|
message: 'Email verified successfully.',
|
||||||
user: this.toPublicUser(savedUser),
|
user: this.toPublicUser(savedUser),
|
||||||
@@ -122,6 +143,14 @@ export class AuthService {
|
|||||||
user.verificationToken = this.createToken();
|
user.verificationToken = this.createToken();
|
||||||
const savedUser = await this.usersRepository.save(user);
|
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, {
|
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||||
email: savedUser.email,
|
email: savedUser.email,
|
||||||
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
verificationUrl: this.createVerificationUrl(savedUser.verificationToken!),
|
||||||
@@ -136,17 +165,42 @@ export class AuthService {
|
|||||||
const user = await this.usersRepository.findOne({ where: { email } });
|
const user = await this.usersRepository.findOne({ where: { email } });
|
||||||
|
|
||||||
if (!user || !this.passwordMatches(password, user.passwordHash)) {
|
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.');
|
throw new UnauthorizedException('Invalid email or password.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.verified) {
|
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.');
|
throw new UnauthorizedException('Please verify your email before login.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: this.toPublicUser(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<AuthTokenResponse> {
|
async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise<AuthTokenResponse> {
|
||||||
@@ -175,10 +229,20 @@ export class AuthService {
|
|||||||
|
|
||||||
await this.refreshTokensRepository.delete({ jti: payload.jti });
|
await this.refreshTokensRepository.delete({ jti: payload.jti });
|
||||||
|
|
||||||
return {
|
const response = {
|
||||||
...(await this.createAuthTokens(user)),
|
...(await this.createAuthTokens(user)),
|
||||||
user: this.toPublicUser(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<JwtTokenPayload> {
|
async verifyAccessToken(accessToken: string): Promise<JwtTokenPayload> {
|
||||||
@@ -243,7 +307,18 @@ export class AuthService {
|
|||||||
|
|
||||||
user.onboardingCompleted = completed;
|
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 {
|
private normalizeEmail(email?: string): string {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuditLogEntity } from '../audit/audit-log.entity';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
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';
|
||||||
@@ -27,6 +28,7 @@ export default new DataSource({
|
|||||||
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)),
|
logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)),
|
||||||
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env),
|
||||||
entities: [
|
entities: [
|
||||||
|
AuditLogEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RefreshTokenEntity,
|
RefreshTokenEntity,
|
||||||
ListTemplateEntity,
|
ListTemplateEntity,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAuditLogs1781200000000 implements MigrationInterface {
|
||||||
|
name = 'CreateAuditLogs1781200000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (await queryRunner.hasTable('audit_logs')) {
|
||||||
|
await queryRunner.query('DROP TABLE `audit_logs`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ListsModule } from '../lists/lists.module';
|
import { ListsModule } from '../lists/lists.module';
|
||||||
import { ListTemplatesController } from './list-templates.controller';
|
import { ListTemplatesController } from './list-templates.controller';
|
||||||
@@ -10,6 +11,7 @@ import { TemplateSeedEntity } from './template-seed.entity';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ListsModule,
|
ListsModule,
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import {
|
import {
|
||||||
AddListTemplateItemDto,
|
AddListTemplateItemDto,
|
||||||
ReorderListTemplateItemsDto,
|
ReorderListTemplateItemsDto,
|
||||||
@@ -35,6 +37,8 @@ export class ListTemplatesService {
|
|||||||
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
|
private readonly templateItemsRepository: Repository<ListTemplateItemEntity>,
|
||||||
@InjectRepository(TemplateSeedEntity)
|
@InjectRepository(TemplateSeedEntity)
|
||||||
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
|
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
|
||||||
|
@Optional()
|
||||||
|
private readonly auditLogService?: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createTemplate(
|
async createTemplate(
|
||||||
@@ -50,7 +54,21 @@ export class ListTemplatesService {
|
|||||||
items: this.createTemplateItems(createDto.items ?? []),
|
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<ListTemplate[]> {
|
async listTemplates(ownerId: string): Promise<ListTemplate[]> {
|
||||||
@@ -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(
|
async deleteTemplate(
|
||||||
@@ -107,8 +143,21 @@ export class ListTemplatesService {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const template = await this.findOwnedTemplate(ownerId, templateId);
|
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.templatesRepository.remove(template);
|
||||||
|
|
||||||
|
await this.auditLogService?.record({
|
||||||
|
actorUserId: ownerId,
|
||||||
|
action: 'template.deleted',
|
||||||
|
entityType: 'template',
|
||||||
|
entityId: templateId,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
return { message: 'List template deleted.' };
|
return { message: 'List template deleted.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +174,19 @@ export class ListTemplatesService {
|
|||||||
template.items.push(savedItem);
|
template.items.push(savedItem);
|
||||||
await this.templatesRepository.save(template);
|
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);
|
return this.getTemplate(ownerId, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +218,21 @@ export class ListTemplatesService {
|
|||||||
await this.templateItemsRepository.save(item);
|
await this.templateItemsRepository.save(item);
|
||||||
await this.templatesRepository.save(template);
|
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);
|
return this.getTemplate(ownerId, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +272,16 @@ export class ListTemplatesService {
|
|||||||
await this.templateItemsRepository.save(reorderedItems);
|
await this.templateItemsRepository.save(reorderedItems);
|
||||||
await this.templatesRepository.save(template);
|
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);
|
return this.getTemplate(ownerId, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +306,18 @@ export class ListTemplatesService {
|
|||||||
await this.templateItemsRepository.save(template.items);
|
await this.templateItemsRepository.save(template.items);
|
||||||
await this.templatesRepository.save(template);
|
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);
|
return this.getTemplate(ownerId, templateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ListsController } from './lists.controller';
|
import { ListsController } from './lists.controller';
|
||||||
import { ListsService } from './lists.service';
|
import { ListsService } from './lists.service';
|
||||||
@@ -7,7 +8,11 @@ import { UserListEntity } from './user-list.entity';
|
|||||||
import { UserListItemEntity } from './user-list-item.entity';
|
import { UserListItemEntity } from './user-list-item.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TypeOrmModule.forFeature([UserListEntity, UserListItemEntity])],
|
imports: [
|
||||||
|
AuditModule,
|
||||||
|
AuthModule,
|
||||||
|
TypeOrmModule.forFeature([UserListEntity, UserListItemEntity]),
|
||||||
|
],
|
||||||
controllers: [ListsController],
|
controllers: [ListsController],
|
||||||
providers: [ListsService],
|
providers: [ListsService],
|
||||||
exports: [ListsService],
|
exports: [ListsService],
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import {
|
import {
|
||||||
ListTemplate,
|
ListTemplate,
|
||||||
ListTemplateKind,
|
ListTemplateKind,
|
||||||
@@ -27,6 +29,8 @@ export class ListsService {
|
|||||||
private readonly listsRepository: Repository<UserListEntity>,
|
private readonly listsRepository: Repository<UserListEntity>,
|
||||||
@InjectRepository(UserListItemEntity)
|
@InjectRepository(UserListItemEntity)
|
||||||
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
||||||
|
@Optional()
|
||||||
|
private readonly auditLogService?: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
||||||
@@ -39,7 +43,20 @@ export class ListsService {
|
|||||||
items: [],
|
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(
|
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<UserList[]> {
|
async listLists(ownerId: string): Promise<UserList[]> {
|
||||||
@@ -111,13 +144,42 @@ export class ListsService {
|
|||||||
list.kind = this.normalizeKind(updateDto.kind);
|
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 }> {
|
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
|
||||||
const list = await this.findOwnedList(ownerId, listId);
|
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.listsRepository.remove(list);
|
||||||
|
|
||||||
|
await this.auditLogService?.record({
|
||||||
|
actorUserId: ownerId,
|
||||||
|
action: 'list.deleted',
|
||||||
|
entityType: 'list',
|
||||||
|
entityId: listId,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
return { message: 'List deleted.' };
|
return { message: 'List deleted.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +196,19 @@ export class ListsService {
|
|||||||
list.items.push(savedItem);
|
list.items.push(savedItem);
|
||||||
await this.listsRepository.save(list);
|
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);
|
return this.getList(ownerId, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +221,7 @@ export class ListsService {
|
|||||||
): Promise<UserList> {
|
): Promise<UserList> {
|
||||||
const list = await this.findOwnedList(ownerId, listId);
|
const list = await this.findOwnedList(ownerId, listId);
|
||||||
const item = this.findListItem(list, itemId);
|
const item = this.findListItem(list, itemId);
|
||||||
|
const wasChecked = item.checked;
|
||||||
|
|
||||||
if (updateDto.title !== undefined) {
|
if (updateDto.title !== undefined) {
|
||||||
item.title = this.requireItemTitle(updateDto.title);
|
item.title = this.requireItemTitle(updateDto.title);
|
||||||
@@ -180,6 +256,29 @@ export class ListsService {
|
|||||||
await this.listItemsRepository.save(item);
|
await this.listItemsRepository.save(item);
|
||||||
await this.listsRepository.save(list);
|
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);
|
return this.getList(ownerId, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +303,18 @@ export class ListsService {
|
|||||||
await this.listItemsRepository.save(list.items);
|
await this.listItemsRepository.save(list.items);
|
||||||
await this.listsRepository.save(list);
|
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);
|
return this.getList(ownerId, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<h2 mat-dialog-title>Liste aus Template erstellen</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||||
|
<mat-dialog-content>
|
||||||
|
<p class="dialog-copy">
|
||||||
|
Vergib einen individuellen Namen für die neue Liste.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Listenname</mat-label>
|
||||||
|
<input matInput formControlName="name" autocomplete="off" />
|
||||||
|
@if (form.controls.name.hasError('required')) {
|
||||||
|
<mat-error>Listenname ist erforderlich.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Beschreibung</mat-label>
|
||||||
|
<textarea matInput formControlName="description" rows="3"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</mat-dialog-content>
|
||||||
|
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
|
||||||
|
<button mat-flat-button type="submit">
|
||||||
|
<mat-icon aria-hidden="true">content_copy</mat-icon>
|
||||||
|
Als Liste erstellen
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
</form>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<CopyTemplateDialogData>(MAT_DIALOG_DATA);
|
||||||
|
private readonly dialogRef = inject(
|
||||||
|
MatDialogRef<CopyTemplateDialogComponent, CopyTemplateDialogResult>,
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|||||||
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';
|
||||||
|
import {
|
||||||
|
CopyTemplateDialogComponent,
|
||||||
|
CopyTemplateDialogResult,
|
||||||
|
} from '../copy-template-dialog/copy-template-dialog.component';
|
||||||
import { ListTemplate } from '../templates.models';
|
import { ListTemplate } from '../templates.models';
|
||||||
import { TemplatesService } from '../templates.service';
|
import { TemplatesService } from '../templates.service';
|
||||||
|
|
||||||
@@ -239,15 +243,35 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
|
|
||||||
protected copyTemplateToList(): void {
|
protected copyTemplateToList(): void {
|
||||||
const templateId = this.templateId();
|
const templateId = this.templateId();
|
||||||
|
const template = this.template();
|
||||||
|
|
||||||
if (!templateId || !this.canEditItems() || this.copyingTemplate()) {
|
if (!templateId || !template || !this.canEditItems() || this.copyingTemplate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.copyingTemplate.set(true);
|
this.copyingTemplate.set(true);
|
||||||
|
|
||||||
this.templatesService
|
this.templatesService
|
||||||
.createListFromTemplate(templateId)
|
.createListFromTemplate(templateId, result)
|
||||||
.pipe(finalize(() => this.copyingTemplate.set(false)))
|
.pipe(finalize(() => this.copyingTemplate.set(false)))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (list) => {
|
next: (list) => {
|
||||||
@@ -261,6 +285,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected deleteTemplate(): void {
|
protected deleteTemplate(): void {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|||||||
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';
|
||||||
|
import {
|
||||||
|
CopyTemplateDialogComponent,
|
||||||
|
CopyTemplateDialogResult,
|
||||||
|
} from './copy-template-dialog/copy-template-dialog.component';
|
||||||
import { ListTemplate, ListTemplateKind } from './templates.models';
|
import { ListTemplate, ListTemplateKind } from './templates.models';
|
||||||
import { TemplatesService } from './templates.service';
|
import { TemplatesService } from './templates.service';
|
||||||
|
|
||||||
@@ -79,10 +83,29 @@ export class TemplatesComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.copyingTemplateId.set(template.id);
|
this.copyingTemplateId.set(template.id);
|
||||||
|
|
||||||
this.templatesService
|
this.templatesService
|
||||||
.createListFromTemplate(template.id)
|
.createListFromTemplate(template.id, result)
|
||||||
.pipe(finalize(() => this.copyingTemplateId.set(null)))
|
.pipe(finalize(() => this.copyingTemplateId.set(null)))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -97,6 +120,7 @@ export class TemplatesComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected deleteTemplate(template: ListTemplate): void {
|
protected deleteTemplate(template: ListTemplate): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user