affiliate-launch-kit / backend /src /modules /export /export-packager.service.ts
mohmmed5787's picture
Deploying Affiliate Launch Kit - Production Ready
d44ff09 verified
Raw
History Blame Contribute Delete
6.11 kB
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import archiver from 'archiver';
import { GeneratedAsset, Export } from '../../database/entities';
import { StorageService } from './storage.service';
export interface ExportRequest {
formats: string[]; // review | bonus | emails | social | cta
bundle_as_zip: boolean;
}
interface BuiltFile {
name: string;
content: string;
}
/**
* Builds export files from a campaign's generated assets and packages them
* (ZIP when bundle_as_zip). This is the queue-ready packaging unit (Section 3.0
* mentions a queue-ready interface; Section 5 recommends BullMQ for export).
*/
@Injectable()
export class ExportPackagerService {
private readonly logger = new Logger('ExportPackager');
constructor(
@InjectRepository(GeneratedAsset) private readonly assetRepo: Repository<GeneratedAsset>,
private readonly storage: StorageService,
) {}
async package(exportId: string, campaignId: string, req: ExportRequest): Promise<{ storage_path: string }> {
const assets = await this.assetRepo.find({ where: { campaign_id: campaignId } });
if (!assets.length) {
throw new NotFoundException({ code: 'NO_ASSETS', message: 'No assets to export' });
}
const byType = new Map(assets.map((a) => [a.asset_type, a]));
const requested = req.formats && req.formats.length ? req.formats : ['review', 'bonus', 'emails', 'social', 'cta'];
const files: BuiltFile[] = [];
if (requested.includes('review') && byType.has('review')) {
files.push({ name: 'review.html', content: this.wrapHtml('Review Page', byType.get('review')!.content) });
}
if (requested.includes('bonus') && byType.has('bonus')) {
files.push({ name: 'bonus.html', content: this.wrapHtml('Bonus Page', byType.get('bonus')!.content) });
}
if (requested.includes('emails') && byType.has('email_sequence')) {
const raw = byType.get('email_sequence')!.content;
files.push({ name: 'emails.json', content: this.prettyJson(raw) });
files.push({ name: 'emails.txt', content: this.emailsToText(raw) });
}
if (requested.includes('social') && byType.has('social_posts')) {
const raw = byType.get('social_posts')!.content;
files.push({ name: 'social.json', content: this.prettyJson(raw) });
files.push({ name: 'social.txt', content: this.socialToText(raw) });
}
if (requested.includes('cta') && byType.has('cta')) {
const raw = byType.get('cta')!.content;
files.push({ name: 'cta.json', content: this.prettyJson(raw) });
files.push({ name: 'cta.txt', content: this.ctaToText(raw) });
}
if (!files.length) {
throw new NotFoundException({ code: 'NO_MATCHING_ASSETS', message: 'No matching assets for selected formats' });
}
const key = `exports/${campaignId}/${exportId}.zip`;
const buffer = await this.buildZip(files);
const storagePath = await this.storage.upload(key, buffer);
this.logger.log(`Packaged ${files.length} files into ${key} (${buffer.length} bytes)`);
return { storage_path: storagePath };
}
private async buildZip(files: BuiltFile[]): Promise<Buffer> {
return new Promise((resolveP, rejectP) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (c: Buffer) => chunks.push(c));
archive.on('end', () => resolveP(Buffer.concat(chunks)));
archive.on('error', rejectP);
for (const f of files) {
archive.append(f.content, { name: f.name });
}
// Also include a README manifest
archive.append(
files.map((f) => `- ${f.name}`).join('\n'),
{ name: 'MANIFEST.txt' },
);
archive.finalize();
});
}
private wrapHtml(title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<style>
body{font-family:Inter,Arial,sans-serif;max-width:760px;margin:40px auto;padding:0 16px;color:#0f172a;line-height:1.6}
.cta-block{background:#f1f5f9;padding:16px 20px;border-radius:8px;margin-top:24px}
.cta-button{display:inline-block;background:#f59e0b;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:700}
.bonus-card{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:12px 0}
.value-tag{display:inline-block;background:#fef3c7;color:#92400e;padding:2px 8px;border-radius:4px;font-size:13px;font-weight:600}
</style>
</head>
<body>
${body}
</body>
</html>`;
}
private prettyJson(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
private emailsToText(raw: string): string {
try {
const arr = JSON.parse(raw);
return arr
.map(
(e: any) =>
`EMAIL ${e.order}\nSubject: ${e.subject}\nPreheader: ${e.preheader}\nCTA: ${e.cta_text}\n\n${e.body}`,
)
.join('\n\n' + '='.repeat(50) + '\n\n');
} catch {
return raw;
}
}
private socialToText(raw: string): string {
try {
const obj = JSON.parse(raw);
const lines: string[] = [];
for (const [platform, data] of Object.entries(obj)) {
const d: any = data;
lines.push(`===== ${platform.toUpperCase()} =====`);
if (d.text) lines.push(d.text);
if (d.caption) lines.push(d.caption);
if (d.hashtags) lines.push(d.hashtags.map((h: string) => `#${h}`).join(' '));
lines.push('');
}
return lines.join('\n');
} catch {
return raw;
}
}
private ctaToText(raw: string): string {
try {
const arr = JSON.parse(raw);
return arr
.map(
(c: any, i: number) =>
`${i + 1}. [${c.urgency_level}] ${c.button_text}\n (${c.placement_context}) ${c.supporting_line}`,
)
.join('\n\n');
} catch {
return raw;
}
}
}