name beim kopieren
This commit is contained in:
@@ -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,
|
||||
|
||||
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 { 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],
|
||||
|
||||
@@ -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<UserEntity>,
|
||||
@InjectRepository(RefreshTokenEntity)
|
||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||
@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<AuthTokenResponse> {
|
||||
@@ -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<JwtTokenPayload> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { 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([
|
||||
|
||||
@@ -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<ListTemplateItemEntity>,
|
||||
@InjectRepository(TemplateSeedEntity)
|
||||
private readonly templateSeedsRepository: Repository<TemplateSeedEntity>,
|
||||
@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<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(
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<UserListEntity>,
|
||||
@InjectRepository(UserListItemEntity)
|
||||
private readonly listItemsRepository: Repository<UserListItemEntity>,
|
||||
@Optional()
|
||||
private readonly auditLogService?: AuditLogService,
|
||||
) {}
|
||||
|
||||
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
||||
@@ -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<UserList[]> {
|
||||
@@ -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<UserList> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user