Spaces:
Runtime error
Runtime error
File size: 6,106 Bytes
d44ff09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | 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;
}
}
}
|