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
68 changes: 67 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"elysia": "^1.4.22",
"elysia-rate-limit": "^4.5.0",
"jsonwebtoken": "^9.0.3",
"pdfmake": "^0.3.3",
"pg": "^8.18.0",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"playwright": "^1.58.2",
"resend": "6.4.2",
"typeorm": "^0.3.28"
},
Expand All @@ -33,6 +36,7 @@
"@eslint/js": "^9.39.2",
"@faker-js/faker": "^10.2.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/pdfmake": "^0.3.0",
"bun-types": "^1.3.8",
"eslint": "^9.39.2",
"globals": "^16.5.0",
Expand Down
61 changes: 61 additions & 0 deletions src/modules/reports/application/GenerateWrappedReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { IReportsRepository } from "../domain/IReportsRepository";
import { IPdfGenerator, WrappedReportData } from "../domain/IPdfGenerator";

export class GenerateWrappedReport {
constructor(
private readonly repository: IReportsRepository,
private readonly pdfGenerator: IPdfGenerator
) { }

async execute(userId: string): Promise<Buffer> {
const seasons = [3, 4, 5];
const playerStats = await this.repository.getPlayerStats(userId, seasons);
const rivals = await this.repository.getTopRivals(userId, seasons);

// Aggregate stats
const totalWins = playerStats.reduce((sum, s) => sum + s.wins, 0);
const totalLosses = playerStats.reduce((sum, s) => sum + s.losses, 0);
const totalMatches = totalWins + totalLosses;
const totalWinRate = totalMatches > 0 ? ((totalWins / totalMatches) * 100).toFixed(1) + "%" : "0%";

const reportData: WrappedReportData = {
totalMatches,
totalWins,
totalLosses,
totalWinRate,
seasons: seasons.map(season => {
const stats = playerStats.find(s => s.season === season);
const rival = rivals.find(r => r.season === season);

if (!stats) return {
season,
stats: null,
rival: null
};

const winRate = (stats.wins + stats.losses) > 0
? ((stats.wins / (stats.wins + stats.losses)) * 100).toFixed(1) + "%"
: "0%";

return {
season,
stats: {
season: stats.season,
points: stats.points,
wins: stats.wins,
losses: stats.losses,
winRate
},
rival: rival ? {
name: rival.rivalName,
wins: rival.wins,
matches: rival.matches
} : null
};
})
};

return this.pdfGenerator.generate(reportData);
}
}

31 changes: 31 additions & 0 deletions src/modules/reports/domain/IPdfGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface SeasonStats {
season: number;
points: number;
wins: number;
losses: number;
winRate: string;
}

export interface SeasonRival {
name: string;
wins: number;
matches: number;
}

export interface SeasonReportData {
season: number;
stats: SeasonStats | null;
rival: SeasonRival | null;
}

export interface WrappedReportData {
totalMatches: number;
totalWins: number;
totalLosses: number;
totalWinRate: string;
seasons: SeasonReportData[];
}

export interface IPdfGenerator {
generate(data: WrappedReportData): Promise<Buffer>;
}
19 changes: 19 additions & 0 deletions src/modules/reports/domain/IReportsRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface PlayerSeasonStats {
season: number;
wins: number;
losses: number;
points: number;
}

export interface PlayerRival {
season: number;
rivalId: string;
rivalName: string;
matches: number;
wins: number;
}

export interface IReportsRepository {
getPlayerStats(userId: string, seasons: number[]): Promise<PlayerSeasonStats[]>;
getTopRivals(userId: string, seasons: number[]): Promise<PlayerRival[]>;
}
175 changes: 175 additions & 0 deletions src/modules/reports/infrastructure/PdfMakeGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const PdfPrinter = require('pdfmake/js/Printer').default;
import { TDocumentDefinitions } from "pdfmake/interfaces";
import { IPdfGenerator, WrappedReportData } from "../domain/IPdfGenerator";

export class PdfMakeGenerator implements IPdfGenerator {
async generate(data: WrappedReportData): Promise<Buffer> {
const fonts = {
Roboto: {
normal: "node_modules/pdfmake/fonts/Roboto/Roboto-Regular.ttf",
bold: "node_modules/pdfmake/fonts/Roboto/Roboto-Medium.ttf",
italics: "node_modules/pdfmake/fonts/Roboto/Roboto-Italic.ttf",
bolditalics: "node_modules/pdfmake/fonts/Roboto/Roboto-MediumItalic.ttf"
}
};

const printer = new PdfPrinter(fonts);

const docDefinition: TDocumentDefinitions = {
pageMargins: [40, 60, 40, 60],
defaultStyle: {
font: 'Roboto',
fontSize: 12,
color: '#333333'
},
background: [
{
canvas: [
{ type: 'rect', x: 0, y: 0, w: 595.28, h: 841.89, color: '#f8f9fa' } // Light gray background
]
}
],
content: [
{
text: "✨ YOUR EVOLUTION WRAPPED ✨",
style: "header",
alignment: "center",
margin: [0, 20, 0, 40]
},
{
columns: [
{ width: '*', text: '' },
{
width: 'auto',
stack: [
{ text: "🏆 ALL TIME STATS (S3-S5)", style: "subheader", alignment: "center" },
{
table: {
body: [
[
{ text: "🔥 Matches", style: "statLabel" },
{ text: data.totalMatches.toString(), style: "statVal" },
{ text: "✅ Wins", style: "statLabel" },
{ text: data.totalWins.toString(), style: "statVal" }
],
[
{ text: "❌ Losses", style: "statLabel" },
{ text: data.totalLosses.toString(), style: "statVal" },
{ text: "📈 Win Rate", style: "statLabel" },
{ text: data.totalWinRate, style: "statVal" }
]
]
},
layout: 'noBorders',
style: "statTable"
}
]
},
{ width: '*', text: '' }
]
},
{ text: "", margin: [0, 20] },
// Season Breakdown
...data.seasons.map(seasonData => {
const stats = seasonData.stats;
const rival = seasonData.rival;

if (!stats) return null;

return [
{
canvas: [
{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 1, lineColor: '#e0e0e0' }
],
margin: [0, 20, 0, 20]
},
{
text: `📅 SEASON ${seasonData.season}`,
style: "seasonHeader",
margin: [0, 0, 0, 10]
},
{
columns: [
{
width: '50%',
stack: [
{ text: `Points: ${stats.points} 💎`, margin: [0, 2, 0, 2] },
{ text: `Record: ${stats.wins}W - ${stats.losses}L`, margin: [0, 2, 0, 2] },
{ text: `Win Rate: ${stats.winRate}`, margin: [0, 2, 0, 2] }
],
style: "seasonStats"
},
{
width: '50%',
stack: [
{ text: "💀 Top Rival:", bold: true, color: '#555' },
rival ? { text: `${rival.name.toUpperCase()}`, fontSize: 14, bold: true, margin: [0, 2, 0, 0] } : { text: "No matches", italics: true, color: '#999' },
rival ? { text: `${rival.wins} wins in ${rival.matches} games`, fontSize: 10, color: '#777' } : {}
],
alignment: 'right'
}
]
}
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}).filter(Boolean) as any[]
],
styles: {
header: {
fontSize: 26,
bold: true,
color: '#1a1a1a',
characterSpacing: 2
},
subheader: {
fontSize: 16,
bold: true,
color: '#444444',
margin: [0, 0, 0, 10]
},
seasonHeader: {
fontSize: 18,
bold: true,
color: '#2c3e50'
},
statLabel: {
fontSize: 12,
color: '#666666',
margin: [0, 5, 10, 5]
},
statVal: {
fontSize: 14,
bold: true,
color: '#000000',
margin: [0, 5, 20, 5]
},
statTable: {
margin: [0, 10, 0, 10]
},
seasonStats: {
fontSize: 12,
color: '#444'
}
}
};

return new Promise((resolve, reject) => {
try {
const pdfDoc = printer.createPdfKitDocument(docDefinition);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise.resolve(pdfDoc).then((doc: any) => {
const chunks: Uint8Array[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
doc.on('data', (chunk: any) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
doc.on('error', (err: any) => reject(err));
doc.end();
}).catch(reject);
} catch (err) {
reject(err);
}
});
}
}
22 changes: 22 additions & 0 deletions src/modules/reports/infrastructure/ReportsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { GenerateWrappedReport } from "../application/GenerateWrappedReport";
import { ReportsPostgresRepository } from "../infrastructure/ReportsPostgresRepository";
import { PdfMakeGenerator } from "../infrastructure/PdfMakeGenerator";

export class ReportsController {
async getWrapped(context: { user: { profile: { id: string } } }) {
const userId = context.user.profile.id;
const generateReport = new GenerateWrappedReport(
new ReportsPostgresRepository(),
new PdfMakeGenerator()
);

const pdfBuffer = await generateReport.execute(userId);

return new Response(pdfBuffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="evolution-wrapped-${userId}.pdf"`
}
});
}
}
Loading