diff --git a/microservices/export-service/Dockerfile b/microservices/export-service/Dockerfile new file mode 100644 index 0000000..700790a --- /dev/null +++ b/microservices/export-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +EXPOSE 3020 +CMD ["node", "dist/main"] diff --git a/microservices/export-service/src/export/entities/export-format.entity.ts b/microservices/export-service/src/export/entities/export-format.entity.ts new file mode 100644 index 0000000..31219b8 --- /dev/null +++ b/microservices/export-service/src/export/entities/export-format.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('export_formats') +export class ExportFormatConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ default: true }) + isEnabled: boolean; + + @Column({ nullable: true }) + description: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/microservices/export-service/src/export/entities/export-job.entity.ts b/microservices/export-service/src/export/entities/export-job.entity.ts new file mode 100644 index 0000000..511723e --- /dev/null +++ b/microservices/export-service/src/export/entities/export-job.entity.ts @@ -0,0 +1,35 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +export enum JobStatus { + QUEUED = 'queued', + RUNNING = 'running', + DONE = 'done', + FAILED = 'failed', +} + +@Entity('export_jobs') +export class ExportJob { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + exportId: string; + + @Column({ type: 'enum', enum: JobStatus, default: JobStatus.QUEUED }) + status: JobStatus; + + @Column({ nullable: true }) + errorMessage: string; + + @Column({ type: 'timestamp', nullable: true }) + startedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/microservices/export-service/src/export/entities/export.entity.ts b/microservices/export-service/src/export/entities/export.entity.ts new file mode 100644 index 0000000..83594d9 --- /dev/null +++ b/microservices/export-service/src/export/entities/export.entity.ts @@ -0,0 +1,47 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +export enum ExportStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +export enum ExportFormat { + CSV = 'csv', + JSON = 'json', + PDF = 'pdf', +} + +@Entity('exports') +export class Export { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + playerId: string; + + @Column({ type: 'enum', enum: ExportFormat, default: ExportFormat.JSON }) + format: ExportFormat; + + @Column({ type: 'enum', enum: ExportStatus, default: ExportStatus.PENDING }) + status: ExportStatus; + + @Column({ nullable: true }) + filePath: string; + + @Column({ nullable: true }) + encryptionKeyId: string; + + @Column({ nullable: true }) + errorMessage: string; + + @Column({ type: 'timestamp', nullable: true }) + deliveredAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/microservices/export-service/src/export/export.controller.ts b/microservices/export-service/src/export/export.controller.ts new file mode 100644 index 0000000..145a316 --- /dev/null +++ b/microservices/export-service/src/export/export.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ExportService } from './export.service'; +import { ExportFormat } from './entities/export.entity'; + +@Controller('export') +export class ExportController { + constructor(private readonly exportService: ExportService) {} + + @Post() + create(@Body() body: { playerId: string; format: ExportFormat }) { + return this.exportService.createExport(body.playerId, body.format); + } + + @Get(':playerId/data') + getData(@Param('playerId') playerId: string) { + return this.exportService.aggregatePlayerData(playerId); + } +} diff --git a/microservices/export-service/src/export/export.module.ts b/microservices/export-service/src/export/export.module.ts new file mode 100644 index 0000000..feceb73 --- /dev/null +++ b/microservices/export-service/src/export/export.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Export } from './entities/export.entity'; +import { ExportJob } from './entities/export-job.entity'; +import { ExportFormatConfig } from './entities/export-format.entity'; +import { ExportService } from './export.service'; +import { ExportController } from './export.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Export, ExportJob, ExportFormatConfig])], + providers: [ExportService], + controllers: [ExportController], + exports: [ExportService], +}) +export class ExportModule {} diff --git a/microservices/export-service/src/export/export.service.ts b/microservices/export-service/src/export/export.service.ts new file mode 100644 index 0000000..4a723fa --- /dev/null +++ b/microservices/export-service/src/export/export.service.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createCipheriv, randomBytes } from 'crypto'; +import { Export, ExportFormat, ExportStatus } from './entities/export.entity'; +import { ExportJob, JobStatus } from './entities/export-job.entity'; + +@Injectable() +export class ExportService { + private readonly logger = new Logger(ExportService.name); + + constructor( + @InjectRepository(Export) + private readonly exportRepo: Repository, + @InjectRepository(ExportJob) + private readonly jobRepo: Repository, + ) {} + + async createExport(playerId: string, format: ExportFormat): Promise { + const exp = this.exportRepo.create({ playerId, format }); + const saved = await this.exportRepo.save(exp); + await this.jobRepo.save(this.jobRepo.create({ exportId: saved.id })); + return saved; + } + + async aggregatePlayerData(playerId: string): Promise> { + // Aggregates all player data — extend with real DB queries per domain + return { + playerId, + exportedAt: new Date().toISOString(), + profile: {}, + quests: [], + achievements: [], + inventory: [], + transactions: [], + }; + } + + toJson(data: Record): string { + return JSON.stringify(data, null, 2); + } + + toCsv(data: Record): string { + const flat = this.flatten(data); + const headers = Object.keys(flat).join(','); + const values = Object.values(flat) + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(','); + return `${headers}\n${values}`; + } + + toPdf(data: Record): string { + // Returns a minimal text-based PDF representation + const lines = Object.entries(this.flatten(data)) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + return `%PDF-1.4\n% Player Data Export\n${lines}`; + } + + encrypt(plaintext: string): { encrypted: string; iv: string; key: string } { + const key = randomBytes(32); + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-cbc', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + encrypted: encrypted.toString('base64'), + iv: iv.toString('hex'), + key: key.toString('hex'), + }; + } + + async markCompleted(exportId: string, filePath: string): Promise { + await this.exportRepo.update(exportId, { status: ExportStatus.COMPLETED, filePath }); + await this.jobRepo.update( + { exportId }, + { status: JobStatus.DONE, completedAt: new Date() }, + ); + } + + async markFailed(exportId: string, error: string): Promise { + await this.exportRepo.update(exportId, { status: ExportStatus.FAILED, errorMessage: error }); + await this.jobRepo.update({ exportId }, { status: JobStatus.FAILED, errorMessage: error }); + } + + private flatten( + obj: Record, + prefix = '', + ): Record { + return Object.entries(obj).reduce>((acc, [k, v]) => { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + Object.assign(acc, this.flatten(v as Record, key)); + } else { + acc[key] = Array.isArray(v) ? JSON.stringify(v) : String(v ?? ''); + } + return acc; + }, {}); + } +} diff --git a/microservices/export-service/src/main.ts b/microservices/export-service/src/main.ts new file mode 100644 index 0000000..beba442 --- /dev/null +++ b/microservices/export-service/src/main.ts @@ -0,0 +1,9 @@ +import { NestFactory } from '@nestjs/core'; +import { ExportModule } from './export/export.module'; + +async function bootstrap() { + const app = await NestFactory.create(ExportModule); + app.setGlobalPrefix('api'); + await app.listen(process.env.PORT ?? 3020); +} +bootstrap();