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, 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 { 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 ` ${title} ${body} `; } 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; } } }