diff --git a/backend/src/migrations/1800430000000-CreateReportScheduleTables.ts b/backend/src/migrations/1800430000000-CreateReportScheduleTables.ts new file mode 100644 index 000000000..ae18466f4 --- /dev/null +++ b/backend/src/migrations/1800430000000-CreateReportScheduleTables.ts @@ -0,0 +1,143 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class CreateReportScheduleTables1800430000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'report_schedules', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'userId', type: 'uuid', isNullable: false }, + { + name: 'reportType', + type: 'enum', + enum: ['DAILY_SUMMARY', 'WEEKLY_ANALYTICS', 'MONTHLY_STATEMENT'], + isNullable: false, + }, + { + name: 'format', + type: 'enum', + enum: ['PDF', 'CSV', 'EXCEL'], + default: "'PDF'", + isNullable: false, + }, + { + name: 'frequency', + type: 'enum', + enum: ['DAILY', 'WEEKLY', 'MONTHLY'], + isNullable: false, + }, + { + name: 'status', + type: 'enum', + enum: ['ACTIVE', 'PAUSED', 'CANCELLED'], + default: "'ACTIVE'", + isNullable: false, + }, + { name: 'emailDelivery', type: 'boolean', default: true }, + { name: 'nextRunAt', type: 'timestamptz', isNullable: false }, + { name: 'isAdmin', type: 'boolean', default: false }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'report_schedules', + new TableForeignKey({ + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'report_archives', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'userId', type: 'uuid', isNullable: false }, + { name: 'scheduleId', type: 'uuid', isNullable: true }, + { + name: 'reportType', + type: 'enum', + enum: ['DAILY_SUMMARY', 'WEEKLY_ANALYTICS', 'MONTHLY_STATEMENT'], + isNullable: false, + }, + { + name: 'format', + type: 'enum', + enum: ['PDF', 'CSV', 'EXCEL'], + isNullable: false, + }, + { name: 'storagePath', type: 'varchar', isNullable: false }, + { name: 'filename', type: 'varchar', isNullable: false }, + { + name: 'status', + type: 'enum', + enum: ['GENERATED', 'DELIVERED', 'FAILED'], + default: "'GENERATED'", + isNullable: false, + }, + { name: 'periodLabel', type: 'varchar', isNullable: true }, + { name: 'generatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'report_archives', + new TableForeignKey({ + columnNames: ['userId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'report_archives', + new TableForeignKey({ + columnNames: ['scheduleId'], + referencedTableName: 'report_schedules', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.createIndex( + 'report_archives', + new TableIndex({ + name: 'IDX_REPORT_ARCHIVES_USER_ID', + columnNames: ['userId', 'generatedAt'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('report_archives'); + await queryRunner.dropTable('report_schedules'); + } +} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index f4ff6abe5..2203b3611 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -289,4 +289,34 @@ export class MailService { ); } } + + async sendReportEmail( + userEmail: string, + name: string, + reportType: string, + periodLabel: string, + attachment: Buffer, + filename: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: `Your ${reportType.replace(/_/g, ' ')} report — ${periodLabel}`, + template: './savings-alert', + context: { + name: name || 'User', + message: `Your scheduled ${reportType.replace(/_/g, ' ').toLowerCase()} report for ${periodLabel} is attached.`, + }, + attachments: [ + { + filename, + content: attachment, + }, + ], + }); + this.logger.log(`Report email sent to ${userEmail}`); + } catch (error) { + this.logger.error(`Failed to send report email to ${userEmail}`, error); + } + } } diff --git a/backend/src/modules/reports/dto/report-schedule.dto.ts b/backend/src/modules/reports/dto/report-schedule.dto.ts new file mode 100644 index 000000000..6a4eb8aff --- /dev/null +++ b/backend/src/modules/reports/dto/report-schedule.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsOptional } from 'class-validator'; +import { + ReportType, + ReportFormat, + ReportScheduleFrequency, +} from '../entities/report-schedule.entity'; + +export class CreateReportScheduleDto { + @ApiProperty({ enum: ReportType }) + @IsEnum(ReportType) + reportType: ReportType; + + @ApiProperty({ enum: ReportFormat, default: ReportFormat.PDF }) + @IsEnum(ReportFormat) + format: ReportFormat; + + @ApiProperty({ enum: ReportScheduleFrequency }) + @IsEnum(ReportScheduleFrequency) + frequency: ReportScheduleFrequency; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + emailDelivery?: boolean; +} diff --git a/backend/src/modules/reports/entities/report-schedule.entity.ts b/backend/src/modules/reports/entities/report-schedule.entity.ts new file mode 100644 index 000000000..be3791d7f --- /dev/null +++ b/backend/src/modules/reports/entities/report-schedule.entity.ts @@ -0,0 +1,129 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +export enum ReportType { + DAILY_SUMMARY = 'DAILY_SUMMARY', + WEEKLY_ANALYTICS = 'WEEKLY_ANALYTICS', + MONTHLY_STATEMENT = 'MONTHLY_STATEMENT', +} + +export enum ReportFormat { + PDF = 'PDF', + CSV = 'CSV', + EXCEL = 'EXCEL', +} + +export enum ReportScheduleFrequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} + +export enum ReportScheduleStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + CANCELLED = 'CANCELLED', +} + +@Entity('report_schedules') +export class ReportSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column({ type: 'enum', enum: ReportType }) + reportType: ReportType; + + @Column({ type: 'enum', enum: ReportFormat, default: ReportFormat.PDF }) + format: ReportFormat; + + @Column({ type: 'enum', enum: ReportScheduleFrequency }) + frequency: ReportScheduleFrequency; + + @Column({ + type: 'enum', + enum: ReportScheduleStatus, + default: ReportScheduleStatus.ACTIVE, + }) + status: ReportScheduleStatus; + + @Column({ type: 'boolean', default: true }) + emailDelivery: boolean; + + @Column({ type: 'timestamptz' }) + nextRunAt: Date; + + @Column({ type: 'boolean', default: false }) + isAdmin: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; +} + +export enum ReportArchiveStatus { + GENERATED = 'GENERATED', + DELIVERED = 'DELIVERED', + FAILED = 'FAILED', +} + +@Entity('report_archives') +export class ReportArchive { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userId: string; + + @Column('uuid', { nullable: true }) + scheduleId: string | null; + + @Column({ type: 'enum', enum: ReportType }) + reportType: ReportType; + + @Column({ type: 'enum', enum: ReportFormat }) + format: ReportFormat; + + @Column({ type: 'varchar' }) + storagePath: string; + + @Column({ type: 'varchar' }) + filename: string; + + @Column({ + type: 'enum', + enum: ReportArchiveStatus, + default: ReportArchiveStatus.GENERATED, + }) + status: ReportArchiveStatus; + + @Column({ type: 'varchar', nullable: true }) + periodLabel: string | null; + + @CreateDateColumn() + generatedAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ManyToOne(() => ReportSchedule, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'scheduleId' }) + schedule: ReportSchedule | null; +} diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index d0444b2df..0ec71fd78 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -1,20 +1,37 @@ import { Controller, Get, + Post, + Patch, + Delete, Param, + Body, Query, Res, BadRequestException, UseGuards, + HttpCode, + HttpStatus, } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; +import { ScheduledReportService } from './scheduled-report.service'; +import { CreateReportScheduleDto } from './dto/report-schedule.dto'; +import { + ReportSchedule, + ReportArchive, +} from './entities/report-schedule.entity'; import { Response } from 'express'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; +@ApiTags('reports') @Controller('reports') export class ReportsController { - constructor(private readonly reportsService: ReportsService) {} + constructor( + private readonly reportsService: ReportsService, + private readonly scheduledReportService: ScheduledReportService, + ) {} @UseGuards(JwtAuthGuard) @Get('tax/:year') @@ -39,4 +56,59 @@ export class ReportsController { return res.json({ storedPath: result.path, filename: result.filename }); } + + @Post('schedules') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a scheduled report configuration' }) + async createSchedule( + @CurrentUser() user: { id: string }, + @Body() dto: CreateReportScheduleDto, + ): Promise { + return this.scheduledReportService.createSchedule(user.id, dto); + } + + @Get('schedules') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List report schedules for current user' }) + async listSchedules( + @CurrentUser() user: { id: string }, + ): Promise { + return this.scheduledReportService.listSchedules(user.id); + } + + @Patch('schedules/:id/pause') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Pause a report schedule' }) + async pauseSchedule( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.scheduledReportService.pauseSchedule(id, user.id); + } + + @Delete('schedules/:id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Cancel a report schedule' }) + async cancelSchedule( + @Param('id') id: string, + @CurrentUser() user: { id: string }, + ): Promise { + return this.scheduledReportService.cancelSchedule(id, user.id); + } + + @Get('archives') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List generated report archives for current user' }) + async listArchives( + @CurrentUser() user: { id: string }, + ): Promise { + return this.scheduledReportService.listArchives(user.id); + } } diff --git a/backend/src/modules/reports/reports.module.ts b/backend/src/modules/reports/reports.module.ts index 9f610ddcc..01c4588ce 100644 --- a/backend/src/modules/reports/reports.module.ts +++ b/backend/src/modules/reports/reports.module.ts @@ -1,15 +1,32 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; import { ReportsController } from './reports.controller'; import { ReportsService } from './reports.service'; +import { ScheduledReportService } from './scheduled-report.service'; import { Transaction } from '../transactions/entities/transaction.entity'; import { SavingsProduct } from '../savings/entities/savings-product.entity'; import { User } from '../user/entities/user.entity'; +import { + ReportSchedule, + ReportArchive, +} from './entities/report-schedule.entity'; +import { MailModule } from '../mail/mail.module'; @Module({ - imports: [TypeOrmModule.forFeature([Transaction, SavingsProduct, User])], + imports: [ + ScheduleModule.forRoot(), + MailModule, + TypeOrmModule.forFeature([ + Transaction, + SavingsProduct, + User, + ReportSchedule, + ReportArchive, + ]), + ], controllers: [ReportsController], - providers: [ReportsService], - exports: [ReportsService], + providers: [ReportsService, ScheduledReportService], + exports: [ReportsService, ScheduledReportService], }) export class ReportsModule {} diff --git a/backend/src/modules/reports/scheduled-report.service.spec.ts b/backend/src/modules/reports/scheduled-report.service.spec.ts new file mode 100644 index 000000000..6ef216b99 --- /dev/null +++ b/backend/src/modules/reports/scheduled-report.service.spec.ts @@ -0,0 +1,106 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ScheduledReportService } from './scheduled-report.service'; +import { + ReportSchedule, + ReportArchive, + ReportType, + ReportFormat, + ReportScheduleFrequency, + ReportScheduleStatus, +} from './entities/report-schedule.entity'; +import { ReportsService } from './reports.service'; +import { User } from '../user/entities/user.entity'; +import { MailService } from '../mail/mail.service'; + +const mockRepo = () => ({ + create: jest.fn((v) => v), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +describe('ScheduledReportService', () => { + let service: ScheduledReportService; + let scheduleRepo: ReturnType; + + beforeEach(async () => { + scheduleRepo = mockRepo(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ScheduledReportService, + { provide: getRepositoryToken(ReportSchedule), useValue: scheduleRepo }, + { provide: getRepositoryToken(ReportArchive), useValue: mockRepo() }, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + { + provide: ReportsService, + useValue: { + generateTaxReportCSV: jest.fn(), + generatePdfBufferFromText: jest.fn(), + }, + }, + { + provide: MailService, + useValue: { sendReportEmail: jest.fn() }, + }, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(ScheduledReportService); + }); + + describe('createSchedule', () => { + it('creates an active schedule', async () => { + const dto = { + reportType: ReportType.DAILY_SUMMARY, + format: ReportFormat.PDF, + frequency: ReportScheduleFrequency.DAILY, + }; + scheduleRepo.save.mockResolvedValue({ id: 'sched-1', ...dto }); + + const result = await service.createSchedule('user-1', dto); + expect(result.id).toBe('sched-1'); + expect(scheduleRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ status: ReportScheduleStatus.ACTIVE }), + ); + }); + }); + + describe('pauseSchedule', () => { + it('pauses schedule', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 's1', + userId: 'u1', + status: ReportScheduleStatus.ACTIVE, + }); + scheduleRepo.save.mockImplementation((s) => Promise.resolve(s)); + + const result = await service.pauseSchedule('s1', 'u1'); + expect(result.status).toBe(ReportScheduleStatus.PAUSED); + }); + + it('throws when not found', async () => { + scheduleRepo.findOne.mockResolvedValue(null); + await expect(service.pauseSchedule('bad', 'u1')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('computeNextRun', () => { + it('adds 1 day for DAILY', () => { + const from = new Date('2026-06-01T12:00:00Z'); + const next = service.computeNextRun(ReportScheduleFrequency.DAILY, from); + expect(next.getDate()).toBe(2); + expect(next.getHours()).toBe(8); + }); + }); +}); diff --git a/backend/src/modules/reports/scheduled-report.service.ts b/backend/src/modules/reports/scheduled-report.service.ts new file mode 100644 index 000000000..ef3fa3153 --- /dev/null +++ b/backend/src/modules/reports/scheduled-report.service.ts @@ -0,0 +1,306 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import { format as csvFormat } from '@fast-csv/format'; +import { + ReportArchive, + ReportArchiveStatus, + ReportFormat, + ReportSchedule, + ReportScheduleFrequency, + ReportScheduleStatus, + ReportType, +} from './entities/report-schedule.entity'; +import { CreateReportScheduleDto } from './dto/report-schedule.dto'; +import { ReportsService } from './reports.service'; +import { User } from '../user/entities/user.entity'; +import { MailService } from '../mail/mail.service'; +import { ShutdownTrackedTask } from '../../common/decorators/shutdown-task.decorator'; + +@Injectable() +export class ScheduledReportService { + private readonly logger = new Logger(ScheduledReportService.name); + private readonly archiveDir: string; + + constructor( + @InjectRepository(ReportSchedule) + private readonly scheduleRepo: Repository, + @InjectRepository(ReportArchive) + private readonly archiveRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly reportsService: ReportsService, + private readonly mailService: MailService, + private readonly configService: ConfigService, + ) { + this.archiveDir = path.resolve( + __dirname, + '..', + '..', + '..', + 'uploads', + 'report-archives', + ); + if (!fs.existsSync(this.archiveDir)) { + fs.mkdirSync(this.archiveDir, { recursive: true }); + } + } + + async createSchedule( + userId: string, + dto: CreateReportScheduleDto, + ): Promise { + const schedule = this.scheduleRepo.create({ + userId, + reportType: dto.reportType, + format: dto.format, + frequency: dto.frequency, + emailDelivery: dto.emailDelivery ?? true, + status: ReportScheduleStatus.ACTIVE, + nextRunAt: this.computeNextRun(dto.frequency), + }); + return this.scheduleRepo.save(schedule); + } + + async listSchedules(userId: string): Promise { + return this.scheduleRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async pauseSchedule(id: string, userId: string): Promise { + const schedule = await this.findOwned(id, userId); + schedule.status = ReportScheduleStatus.PAUSED; + return this.scheduleRepo.save(schedule); + } + + async cancelSchedule(id: string, userId: string): Promise { + const schedule = await this.findOwned(id, userId); + schedule.status = ReportScheduleStatus.CANCELLED; + await this.scheduleRepo.save(schedule); + } + + async listArchives(userId: string): Promise { + return this.archiveRepo.find({ + where: { userId }, + order: { generatedAt: 'DESC' }, + }); + } + + @ShutdownTrackedTask() + @Cron(CronExpression.EVERY_HOUR) + async processDueSchedules(): Promise { + const now = new Date(); + const due = await this.scheduleRepo + .createQueryBuilder('s') + .where('s.status = :status', { status: ReportScheduleStatus.ACTIVE }) + .andWhere('s.nextRunAt <= :now', { now }) + .getMany(); + + for (const schedule of due) { + await this.generateAndArchive(schedule); + } + } + + async generateAndArchive(schedule: ReportSchedule): Promise { + const user = await this.userRepo.findOne({ + where: { id: schedule.userId }, + }); + if (!user) { + throw new NotFoundException('User not found for schedule'); + } + + const year = new Date().getFullYear(); + const periodLabel = this.buildPeriodLabel(schedule); + + try { + const { buffer, filename, ext } = await this.generateReportBuffer( + schedule, + user.id, + year, + periodLabel, + ); + + const storagePath = path.join(this.archiveDir, filename); + fs.writeFileSync(storagePath, buffer); + + const archive = this.archiveRepo.create({ + userId: schedule.userId, + scheduleId: schedule.id, + reportType: schedule.reportType, + format: schedule.format, + storagePath, + filename, + status: ReportArchiveStatus.GENERATED, + periodLabel, + }); + const saved = await this.archiveRepo.save(archive); + + if (schedule.emailDelivery && user.email) { + await this.mailService.sendReportEmail( + user.email, + user.name ?? 'User', + schedule.reportType, + periodLabel, + buffer, + `${filename}${ext}`, + ); + saved.status = ReportArchiveStatus.DELIVERED; + await this.archiveRepo.save(saved); + } + + schedule.nextRunAt = this.computeNextRun(schedule.frequency); + await this.scheduleRepo.save(schedule); + + this.logger.log(`Report generated for schedule ${schedule.id}`); + return saved; + } catch (error) { + const archive = this.archiveRepo.create({ + userId: schedule.userId, + scheduleId: schedule.id, + reportType: schedule.reportType, + format: schedule.format, + storagePath: '', + filename: `failed-${Date.now()}`, + status: ReportArchiveStatus.FAILED, + periodLabel, + }); + await this.archiveRepo.save(archive); + throw error; + } + } + + private async generateReportBuffer( + schedule: ReportSchedule, + userId: string, + year: number, + periodLabel: string, + ): Promise<{ buffer: Buffer; filename: string; ext: string }> { + const baseName = `${schedule.reportType.toLowerCase()}_${userId}_${periodLabel.replace(/\s/g, '_')}`; + + switch (schedule.format) { + case ReportFormat.PDF: { + const lines = await this.buildReportLines(schedule, userId, year); + const buffer = await this.reportsService.generatePdfBufferFromText( + `${schedule.reportType} — ${periodLabel}`, + lines, + ); + return { buffer, filename: `${baseName}.pdf`, ext: '.pdf' }; + } + case ReportFormat.EXCEL: + case ReportFormat.CSV: { + const rows = await this.buildReportRows(schedule, userId, year); + const buffer = await this.generateCsvBuffer(rows); + const ext = schedule.format === ReportFormat.EXCEL ? '.xlsx' : '.csv'; + return { buffer, filename: `${baseName}${ext}`, ext }; + } + } + } + + private async buildReportLines( + schedule: ReportSchedule, + userId: string, + year: number, + ): Promise { + const csvBuf = await this.reportsService.generateTaxReportCSV(userId, year); + const lines = csvBuf.toString().split('\n').filter(Boolean); + return [ + `Report Type: ${schedule.reportType}`, + `Generated: ${new Date().toISOString()}`, + '', + ...lines, + ]; + } + + private async buildReportRows( + schedule: ReportSchedule, + userId: string, + year: number, + ): Promise[]> { + const csvBuf = await this.reportsService.generateTaxReportCSV(userId, year); + const rows = csvBuf.toString().split('\n').filter(Boolean); + const header = (rows[0] ?? '').split(','); + return rows.slice(1).map((row) => { + const values = row.split(','); + const record: Record = { + reportType: schedule.reportType, + }; + header.forEach((key, idx) => { + record[key] = values[idx] ?? ''; + }); + return record; + }); + } + + private generateCsvBuffer( + rows: Record[], + ): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const stream = csvFormat({ headers: true }); + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + rows.forEach((row) => stream.write(row)); + stream.end(); + }); + } + + private buildPeriodLabel(schedule: ReportSchedule): string { + const now = new Date(); + switch (schedule.frequency) { + case ReportScheduleFrequency.DAILY: + return now.toISOString().slice(0, 10); + case ReportScheduleFrequency.WEEKLY: + return `week-${this.getWeekNumber(now)}-${now.getFullYear()}`; + case ReportScheduleFrequency.MONTHLY: + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + default: + return now.toISOString().slice(0, 10); + } + } + + private getWeekNumber(date: Date): number { + const start = new Date(date.getFullYear(), 0, 1); + const diff = date.getTime() - start.getTime(); + return Math.ceil((diff / 86400000 + start.getDay() + 1) / 7); + } + + private async findOwned(id: string, userId: string): Promise { + const schedule = await this.scheduleRepo.findOne({ where: { id, userId } }); + if (!schedule) { + throw new NotFoundException(`Report schedule ${id} not found`); + } + return schedule; + } + + computeNextRun(frequency: ReportScheduleFrequency, from = new Date()): Date { + const next = new Date(from); + switch (frequency) { + case ReportScheduleFrequency.DAILY: + next.setDate(next.getDate() + 1); + next.setHours(8, 0, 0, 0); + break; + case ReportScheduleFrequency.WEEKLY: + next.setDate(next.getDate() + 7); + next.setHours(8, 0, 0, 0); + break; + case ReportScheduleFrequency.MONTHLY: + next.setMonth(next.getMonth() + 1); + next.setDate(1); + next.setHours(8, 0, 0, 0); + break; + } + return next; + } +}