Skip to content

Commit 44a6bf9

Browse files
authored
Merge pull request #349 from RUKAYAT-CODER/feat/player-data-export-service
Player Data Export and Portability Service Setup
2 parents 1596319 + c456373 commit 44a6bf9

8 files changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM node:20-alpine AS builder
2+
WORKDIR /app
3+
COPY package*.json ./
4+
RUN npm ci
5+
COPY . .
6+
RUN npm run build
7+
8+
FROM node:20-alpine
9+
WORKDIR /app
10+
COPY --from=builder /app/dist ./dist
11+
COPY --from=builder /app/node_modules ./node_modules
12+
EXPOSE 3020
13+
CMD ["node", "dist/main"]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
2+
3+
@Entity('export_formats')
4+
export class ExportFormatConfig {
5+
@PrimaryGeneratedColumn('uuid')
6+
id: string;
7+
8+
@Column({ unique: true })
9+
name: string;
10+
11+
@Column({ default: true })
12+
isEnabled: boolean;
13+
14+
@Column({ nullable: true })
15+
description: string;
16+
17+
@CreateDateColumn()
18+
createdAt: Date;
19+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2+
3+
export enum JobStatus {
4+
QUEUED = 'queued',
5+
RUNNING = 'running',
6+
DONE = 'done',
7+
FAILED = 'failed',
8+
}
9+
10+
@Entity('export_jobs')
11+
export class ExportJob {
12+
@PrimaryGeneratedColumn('uuid')
13+
id: string;
14+
15+
@Column()
16+
exportId: string;
17+
18+
@Column({ type: 'enum', enum: JobStatus, default: JobStatus.QUEUED })
19+
status: JobStatus;
20+
21+
@Column({ nullable: true })
22+
errorMessage: string;
23+
24+
@Column({ type: 'timestamp', nullable: true })
25+
startedAt: Date;
26+
27+
@Column({ type: 'timestamp', nullable: true })
28+
completedAt: Date;
29+
30+
@CreateDateColumn()
31+
createdAt: Date;
32+
33+
@UpdateDateColumn()
34+
updatedAt: Date;
35+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2+
3+
export enum ExportStatus {
4+
PENDING = 'pending',
5+
PROCESSING = 'processing',
6+
COMPLETED = 'completed',
7+
FAILED = 'failed',
8+
}
9+
10+
export enum ExportFormat {
11+
CSV = 'csv',
12+
JSON = 'json',
13+
PDF = 'pdf',
14+
}
15+
16+
@Entity('exports')
17+
export class Export {
18+
@PrimaryGeneratedColumn('uuid')
19+
id: string;
20+
21+
@Column()
22+
playerId: string;
23+
24+
@Column({ type: 'enum', enum: ExportFormat, default: ExportFormat.JSON })
25+
format: ExportFormat;
26+
27+
@Column({ type: 'enum', enum: ExportStatus, default: ExportStatus.PENDING })
28+
status: ExportStatus;
29+
30+
@Column({ nullable: true })
31+
filePath: string;
32+
33+
@Column({ nullable: true })
34+
encryptionKeyId: string;
35+
36+
@Column({ nullable: true })
37+
errorMessage: string;
38+
39+
@Column({ type: 'timestamp', nullable: true })
40+
deliveredAt: Date;
41+
42+
@CreateDateColumn()
43+
createdAt: Date;
44+
45+
@UpdateDateColumn()
46+
updatedAt: Date;
47+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
2+
import { ExportService } from './export.service';
3+
import { ExportFormat } from './entities/export.entity';
4+
5+
@Controller('export')
6+
export class ExportController {
7+
constructor(private readonly exportService: ExportService) {}
8+
9+
@Post()
10+
create(@Body() body: { playerId: string; format: ExportFormat }) {
11+
return this.exportService.createExport(body.playerId, body.format);
12+
}
13+
14+
@Get(':playerId/data')
15+
getData(@Param('playerId') playerId: string) {
16+
return this.exportService.aggregatePlayerData(playerId);
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { Export } from './entities/export.entity';
4+
import { ExportJob } from './entities/export-job.entity';
5+
import { ExportFormatConfig } from './entities/export-format.entity';
6+
import { ExportService } from './export.service';
7+
import { ExportController } from './export.controller';
8+
9+
@Module({
10+
imports: [TypeOrmModule.forFeature([Export, ExportJob, ExportFormatConfig])],
11+
providers: [ExportService],
12+
controllers: [ExportController],
13+
exports: [ExportService],
14+
})
15+
export class ExportModule {}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { createCipheriv, randomBytes } from 'crypto';
5+
import { Export, ExportFormat, ExportStatus } from './entities/export.entity';
6+
import { ExportJob, JobStatus } from './entities/export-job.entity';
7+
8+
@Injectable()
9+
export class ExportService {
10+
private readonly logger = new Logger(ExportService.name);
11+
12+
constructor(
13+
@InjectRepository(Export)
14+
private readonly exportRepo: Repository<Export>,
15+
@InjectRepository(ExportJob)
16+
private readonly jobRepo: Repository<ExportJob>,
17+
) {}
18+
19+
async createExport(playerId: string, format: ExportFormat): Promise<Export> {
20+
const exp = this.exportRepo.create({ playerId, format });
21+
const saved = await this.exportRepo.save(exp);
22+
await this.jobRepo.save(this.jobRepo.create({ exportId: saved.id }));
23+
return saved;
24+
}
25+
26+
async aggregatePlayerData(playerId: string): Promise<Record<string, unknown>> {
27+
// Aggregates all player data — extend with real DB queries per domain
28+
return {
29+
playerId,
30+
exportedAt: new Date().toISOString(),
31+
profile: {},
32+
quests: [],
33+
achievements: [],
34+
inventory: [],
35+
transactions: [],
36+
};
37+
}
38+
39+
toJson(data: Record<string, unknown>): string {
40+
return JSON.stringify(data, null, 2);
41+
}
42+
43+
toCsv(data: Record<string, unknown>): string {
44+
const flat = this.flatten(data);
45+
const headers = Object.keys(flat).join(',');
46+
const values = Object.values(flat)
47+
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
48+
.join(',');
49+
return `${headers}\n${values}`;
50+
}
51+
52+
toPdf(data: Record<string, unknown>): string {
53+
// Returns a minimal text-based PDF representation
54+
const lines = Object.entries(this.flatten(data))
55+
.map(([k, v]) => `${k}: ${v}`)
56+
.join('\n');
57+
return `%PDF-1.4\n% Player Data Export\n${lines}`;
58+
}
59+
60+
encrypt(plaintext: string): { encrypted: string; iv: string; key: string } {
61+
const key = randomBytes(32);
62+
const iv = randomBytes(16);
63+
const cipher = createCipheriv('aes-256-cbc', key, iv);
64+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
65+
return {
66+
encrypted: encrypted.toString('base64'),
67+
iv: iv.toString('hex'),
68+
key: key.toString('hex'),
69+
};
70+
}
71+
72+
async markCompleted(exportId: string, filePath: string): Promise<void> {
73+
await this.exportRepo.update(exportId, { status: ExportStatus.COMPLETED, filePath });
74+
await this.jobRepo.update(
75+
{ exportId },
76+
{ status: JobStatus.DONE, completedAt: new Date() },
77+
);
78+
}
79+
80+
async markFailed(exportId: string, error: string): Promise<void> {
81+
await this.exportRepo.update(exportId, { status: ExportStatus.FAILED, errorMessage: error });
82+
await this.jobRepo.update({ exportId }, { status: JobStatus.FAILED, errorMessage: error });
83+
}
84+
85+
private flatten(
86+
obj: Record<string, unknown>,
87+
prefix = '',
88+
): Record<string, string> {
89+
return Object.entries(obj).reduce<Record<string, string>>((acc, [k, v]) => {
90+
const key = prefix ? `${prefix}.${k}` : k;
91+
if (v && typeof v === 'object' && !Array.isArray(v)) {
92+
Object.assign(acc, this.flatten(v as Record<string, unknown>, key));
93+
} else {
94+
acc[key] = Array.isArray(v) ? JSON.stringify(v) : String(v ?? '');
95+
}
96+
return acc;
97+
}, {});
98+
}
99+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NestFactory } from '@nestjs/core';
2+
import { ExportModule } from './export/export.module';
3+
4+
async function bootstrap() {
5+
const app = await NestFactory.create(ExportModule);
6+
app.setGlobalPrefix('api');
7+
await app.listen(process.env.PORT ?? 3020);
8+
}
9+
bootstrap();

0 commit comments

Comments
 (0)