Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions microservices/export-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
47 changes: 47 additions & 0 deletions microservices/export-service/src/export/entities/export.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions microservices/export-service/src/export/export.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 15 additions & 0 deletions microservices/export-service/src/export/export.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
99 changes: 99 additions & 0 deletions microservices/export-service/src/export/export.service.ts
Original file line number Diff line number Diff line change
@@ -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<Export>,
@InjectRepository(ExportJob)
private readonly jobRepo: Repository<ExportJob>,
) {}

async createExport(playerId: string, format: ExportFormat): Promise<Export> {
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<Record<string, unknown>> {
// 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, unknown>): string {
return JSON.stringify(data, null, 2);
}

toCsv(data: Record<string, unknown>): 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, unknown>): 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<void> {
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<void> {
await this.exportRepo.update(exportId, { status: ExportStatus.FAILED, errorMessage: error });
await this.jobRepo.update({ exportId }, { status: JobStatus.FAILED, errorMessage: error });
}

private flatten(
obj: Record<string, unknown>,
prefix = '',
): Record<string, string> {
return Object.entries(obj).reduce<Record<string, string>>((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<string, unknown>, key));
} else {
acc[key] = Array.isArray(v) ? JSON.stringify(v) : String(v ?? '');
}
return acc;
}, {});
}
}
9 changes: 9 additions & 0 deletions microservices/export-service/src/main.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading