Files
digicraft-be/services/archiveService.ts
Fahri Can Seçer 80dcf4d04a
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
main
2026-02-05 01:29:22 +03:00

536 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import archiver from 'archiver';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { PrismaClient } from '@prisma/client';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
const STORAGE_ROOT = path.resolve(__dirname, '../../storage');
// Aspect Ratio Map for exact print dimensions
const ASPECT_RATIO_MAP: Record<string, [number, number]> = {
"1:1": [6000, 6000],
"3:4": [4500, 6000],
"4:3": [6000, 4500],
"4:5": [4800, 6000],
"5:4": [6000, 4800],
"9:16": [3375, 6000],
"16:9": [6000, 3375],
"2:3": [4000, 6000],
"3:2": [6000, 4000],
"1:4": [1500, 6000],
"4:1": [6000, 1500],
};
const MIN_PRINT_SIZE = 6000;
export class ArchiveService {
// 1. MASTER ZIP: Everything (Source of Truth)
async createMasterArchive(projectId: string, res: any) {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
});
if (!project) throw new Error("Project not found");
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`MasterBundle_${project.productType}_${projectId.substring(0, 6)}.zip`);
archive.pipe(res);
const qualityLog: string[] = [];
qualityLog.push(`QUALITY VALIDATION REPORT`);
qualityLog.push(`Generated: ${new Date().toISOString()}`);
qualityLog.push(`Project ID: ${projectId}`);
qualityLog.push(`Product Type: ${project.productType}`);
qualityLog.push(`----------------------------------------`);
// A. Add All Assets & Validate (with Print-Ready Enforcement)
const MIN_PRINT_SIZE = 6000; // Minimum long side for print-ready
for (const asset of project.assets) {
const assetPath = path.join(STORAGE_ROOT, asset.path);
if (fs.existsSync(assetPath)) {
const internalPath = `assets/${asset.type}/${path.basename(assetPath)}`;
// VALIDATION & ENFORCEMENT (Only for images)
if (assetPath.endsWith('.png') || assetPath.endsWith('.jpg')) {
try {
const meta = await sharp(assetPath).metadata();
const currentWidth = meta.width || 0;
const currentHeight = meta.height || 0;
const longSide = Math.max(currentWidth, currentHeight);
const isPrintReady = longSide >= MIN_PRINT_SIZE && (meta.density || 72) >= 300;
const colorSpace = meta.channels === 4 ? "RGB+Alpha" : meta.channels === 3 ? "RGB" : "CMYK/Other";
// For MASTER and UPSCALED: Enforce 6000px @ 300 DPI
if (['master', 'upscaled'].includes(asset.type) && !isPrintReady) {
const ratio = currentWidth / currentHeight;
let finalWidth: number;
let finalHeight: number;
if (currentWidth >= currentHeight) {
finalWidth = MIN_PRINT_SIZE;
finalHeight = Math.round(MIN_PRINT_SIZE / ratio);
} else {
finalHeight = MIN_PRINT_SIZE;
finalWidth = Math.round(MIN_PRINT_SIZE * ratio);
}
// Upscale on-the-fly for archive
const upscaledBuffer = await sharp(assetPath)
.resize(finalWidth, finalHeight, { kernel: sharp.kernel.lanczos3, fit: 'fill' })
.withMetadata({ density: 300 })
.png({ quality: 100, compressionLevel: 6 })
.toBuffer();
archive.append(upscaledBuffer, { name: `assets/${asset.type}/PrintReady_${finalWidth}x${finalHeight}_${path.basename(assetPath)}` });
qualityLog.push(`[UPSCALED FOR PRINT] ${internalPath}`);
qualityLog.push(` - Original: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi`);
qualityLog.push(` - Upscaled to: ${finalWidth}x${finalHeight}px @ 300 DPI for print-readiness.`);
} else {
// Add as-is (already compliant or non-master asset)
archive.file(assetPath, { name: internalPath });
const status = isPrintReady ? "PRINT-READY ✓" : (longSide >= 2000 ? "PASS" : "WARN (Low Res)");
qualityLog.push(`[${status}] ${internalPath}`);
qualityLog.push(` - Dims: ${currentWidth}x${currentHeight}px | Density: ${meta.density || 72}dpi | Color: ${colorSpace}`);
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
qualityLog.push(` - WARNING: Asset may be too small for large format printing.`);
}
}
} catch (e) {
qualityLog.push(`[ERROR] validation failed for ${internalPath}: ${(e as Error).message}`);
// Still add the original file on error
archive.file(assetPath, { name: internalPath });
}
} else {
// Non-image assets (text files, etc.)
archive.file(assetPath, { name: internalPath });
}
} else {
qualityLog.push(`[MISSING] Asset record found but file missing: ${asset.path}`);
}
}
// B. Add Restoration Manifest (Machine Readable)
const manifest = {
version: "1.0",
timestamp: new Date().toISOString(),
project: {
id: project.id,
niche: project.niche,
productType: project.productType,
creativity: project.creativity,
aspectRatio: project.aspectRatio
},
seo: project.seoData,
assets: project.assets.map(a => ({ type: a.type, path: a.path, meta: a.meta }))
};
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
// C. Add Quality Report
archive.append(qualityLog.join('\n'), { name: 'docs/QUALITY_REPORT.txt' });
// D. Add Strategy / SEO
if (project.seoData) {
// Construct the State Object exactly how the UI components demand it
// CRITICAL FIX: Fallback to seoData if strategy is missing (Recovered Projects Scenario)
const resolvedStrategy = (project.seoData as any) || {};
const shopName = project.user?.etsyShops?.[0]?.shopName || "The Artist";
// Format Attributes
let attributesStr = "None";
try {
if ((project.seoData as any).attributes) {
const attrs = JSON.parse((project.seoData as any).attributes);
attributesStr = Object.entries(attrs).map(([k, v]) => `- ${k.toUpperCase()}: ${v}`).join('\n');
}
} catch (e) { }
const seoContent = `
TITLE: ${(project as any).sku ? `[${(project as any).sku}] ` : ''}${project.seoData.title}
SKU: ${(project as any).sku || 'Not Assigned'}
CATEGORY PATH: ${(project.seoData as any).categoryPath || "Not set"}
DESCRIPTION:
${project.seoData.description}
KEYWORDS: ${JSON.parse(project.seoData.keywords as string || '[]').join(', ')}
HIDDEN ATTRIBUTES:
${attributesStr}
--------------------------------------
Generated for ${shopName} via DigiCraft
`;
archive.append(seoContent, { name: 'docs/SEO_METADATA.txt' });
if (project.seoData.printingGuide) {
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
if (fs.existsSync(guidePath)) {
archive.file(guidePath, { name: 'docs/Printing_Guide.txt' });
}
}
}
await archive.finalize();
}
// 2. CUSTOMER ARCHIVE: Filtering sensitive info, only deliverables
async createCustomerArchive(projectId: string, mode: 'rgb' | 'cmyk', res: any) {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { assets: true, seoData: true, user: { include: { etsyShops: true } } }
});
if (!project) throw new Error("Project not found");
const shopName = (project.user as any)?.etsyShopName || project.user?.etsyShops?.[0]?.shopName || "The Artist";
const shopUrl = (project.user as any)?.etsyShopLink || "";
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`CustomerDownload_${mode.toUpperCase()}_${projectId.substring(0, 6)}.zip`);
archive.pipe(res);
// ----------------------------------------------------
// A. Neuro-Marketing Experience Pack (Etsy-Compliant)
// ----------------------------------------------------
const readmeContent = `
═══════════════════════════════════════════════════════════════════════════════
✨ THANK YOU FOR CHOOSING ${shopName.toUpperCase()}! ✨
═══════════════════════════════════════════════════════════════════════════════
Congratulations on your wonderful choice! 🎨
We're so excited that you've chosen to bring this artwork into your space.
Every pixel has been crafted with care, and we can't wait to see how you use it.
───────────────────────────────────────────────────────────────────────────────
📥 WHAT'S INSIDE THIS PACKAGE?
───────────────────────────────────────────────────────────────────────────────
${mode === 'cmyk' ? `✓ Professional CMYK Print Files (.tif format)
→ Optimized for commercial printing
→ 300 DPI high-resolution
→ Ready for offset, laser, or inkjet printing` : `✓ Digital RGB Files (.png format)
→ Perfect for digital displays
→ 300 DPI high-resolution
→ Vibrant colors for screens and home printers`}
✓ Multiple Aspect Ratios Included:
→ 3:4 (9x12, 18x24 inches)
→ 4:5 (8x10, 16x20 inches)
→ 2:3 (4x6, 24x36 inches)
→ 5:7 / ISO (A1-A5 international)
→ 11:14 (Exclusive frame size)
───────────────────────────────────────────────────────────────────────────────
🖨️ HOW TO PRINT (3 Easy Steps)
───────────────────────────────────────────────────────────────────────────────
1⃣ Choose your file based on your frame size
2⃣ Upload to a print service (Costco, Walgreens, local print shop)
3⃣ Select "Original Size" or "Fit to Frame" and print!
💡 Pro Tip: For the BEST quality, use a professional print service
with heavyweight matte or luster paper (200gsm+).
───────────────────────────────────────────────────────────────────────────────
💬 WE'D LOVE TO HEAR FROM YOU
───────────────────────────────────────────────────────────────────────────────
Your feedback helps us grow and helps other buyers make informed decisions.
If you have a moment, sharing your experience would mean the world to us.
Whether it's a quick thought or a photo of the art in your space —
we genuinely appreciate every message. 💌
───────────────────────────────────────────────────────────────────────────────
❓ NEED HELP?
───────────────────────────────────────────────────────────────────────────────
Having trouble? We're here for you!
Simply send us a message through Etsy and we'll respond within 24 hours.
With gratitude,
The ${shopName} Team 🌟
${shopUrl ? `\n🔗 Visit our shop: ${shopUrl}` : ''}
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(readmeContent.trim(), { name: '00_READ_ME_FIRST.txt' });
const licenseContent = `
═══════════════════════════════════════════════════════════════════════════════
📜 PERSONAL USE LICENSE AGREEMENT
═══════════════════════════════════════════════════════════════════════════════
Thank you for supporting independent artists! 🙏
This license grants you the following rights:
───────────────────────────────────────────────────────────────────────────────
✅ WHAT YOU CAN DO
───────────────────────────────────────────────────────────────────────────────
• Print and display this artwork in your home or office
• Use as a digital wallpaper on your personal devices
• Gift a PRINTED copy to friends or family
• Print multiple copies for your own personal spaces
───────────────────────────────────────────────────────────────────────────────
❌ WHAT'S NOT ALLOWED
───────────────────────────────────────────────────────────────────────────────
• Reselling, sharing, or redistributing the digital files
• Uploading to print-on-demand sites (Redbubble, Amazon, Etsy, etc.)
• Using for commercial products without a commercial license
• Claiming this artwork as your own creation
• Modifying and reselling as a "new" product
───────────────────────────────────────────────────────────────────────────────
🛡️ COPYRIGHT NOTICE
───────────────────────────────────────────────────────────────────────────────
This artwork is protected by copyright law.
Unauthorized distribution or commercial use is prohibited.
We put our heart into every design. Thank you for respecting
the rights of independent artists and small businesses. ❤️
© ${new Date().getFullYear()} ${shopName}. All Rights Reserved.
───────────────────────────────────────────────────────────────────────────────
📧 QUESTIONS ABOUT LICENSING?
───────────────────────────────────────────────────────────────────────────────
Need a commercial license? Want to use this for a business project?
Just send us a message — we're happy to discuss custom licensing options!
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(licenseContent.trim(), { name: '01_LICENSE.txt' });
// NEW: Thank You Card with Soft Review Request
const thankYouContent = `
═══════════════════════════════════════════════════════════════════════════════
💌 A PERSONAL NOTE FROM THE ARTIST
═══════════════════════════════════════════════════════════════════════════════
Hey there, art lover! 👋
I wanted to take a moment to personally thank you for your purchase.
As a small business owner, every order feels like a little celebration.
Your support allows me to keep creating, keep dreaming, and keep
bringing beautiful art into the world. That means everything.
───────────────────────────────────────────────────────────────────────────────
🌟 SHARE YOUR EXPERIENCE
───────────────────────────────────────────────────────────────────────────────
If your order arrived safely and you're happy with the quality,
I'd be incredibly grateful if you could take a moment to share
your experience with other buyers.
Your honest feedback — whatever it may be — helps others discover
our work and helps us understand how we can serve you better.
(No pressure, no expectations. Just genuine appreciation for
any thoughts you're willing to share. 🙏)
───────────────────────────────────────────────────────────────────────────────
📸 WE LOVE SEEING YOUR SPACE!
───────────────────────────────────────────────────────────────────────────────
Hung up your print? Styled it on your desk?
We'd love to see how you've decorated your space!
Tag us or send a photo — we might feature you on our page!
───────────────────────────────────────────────────────────────────────────────
Until next time,
${shopName} 🎨✨
${shopUrl ? `\n🔗 ${shopUrl}` : ''}
═══════════════════════════════════════════════════════════════════════════════
`;
archive.append(thankYouContent.trim(), { name: '02_THANK_YOU.txt' });
// ----------------------------------------------------
// B. Add Deliverables (with Print-Ready Enforcement)
// ----------------------------------------------------
// Filter: Only Master and Variants
const deliverables = project.assets.filter(a => ['master', 'variant', 'upscaled'].includes(a.type));
// Get target dimensions from project's aspect ratio
const targetRatio = project.aspectRatio || "3:4";
let targetWidth: number;
let targetHeight: number;
if (ASPECT_RATIO_MAP[targetRatio]) {
[targetWidth, targetHeight] = ASPECT_RATIO_MAP[targetRatio];
} else {
// Fallback for unknown ratios
const [w, h] = targetRatio.split(':').map(Number);
if (w > h) {
targetWidth = MIN_PRINT_SIZE;
targetHeight = Math.round(MIN_PRINT_SIZE * h / w);
} else {
targetHeight = MIN_PRINT_SIZE;
targetWidth = Math.round(MIN_PRINT_SIZE * w / h);
}
}
const nameCount: Record<string, number> = {};
for (const asset of deliverables) {
const assetPath = path.join(STORAGE_ROOT, asset.path);
if (fs.existsSync(assetPath)) {
// filename logic: SEO Title (truncated) + Ratio
let safeTitle = "Artwork";
if (project.seoData?.title) {
safeTitle = project.seoData.title
.replace(/[^a-zA-Z0-9\s-_]/g, '') // Remove special chars
.trim()
.replace(/\s+/g, '_'); // Replace spaces with underscores
if (safeTitle.length > 40) {
safeTitle = safeTitle.substring(0, 40);
}
}
try {
const meta = await sharp(assetPath).metadata();
const currentWidth = meta.width || 0;
const currentHeight = meta.height || 0;
// Check if image is already at target specifications
const isPrintReady = (currentWidth === targetWidth && currentHeight === targetHeight);
// Determine Ratio String
let ratioStr = "Original";
// Simple GCD or lookup could be better, but approximation works for filenames
const r = currentWidth / currentHeight;
if (Math.abs(r - 3 / 4) < 0.05) ratioStr = "3x4";
else if (Math.abs(r - 4 / 5) < 0.05) ratioStr = "4x5";
else if (Math.abs(r - 2 / 3) < 0.05) ratioStr = "2x3";
else if (Math.abs(r - 9 / 16) < 0.05) ratioStr = "9x16";
else if (Math.abs(r - 5 / 7) < 0.05) ratioStr = "5x7";
else if (Math.abs(r - 11 / 14) < 0.05) ratioStr = "11x14";
else if (Math.abs(r - 1) < 0.05) ratioStr = "1x1";
else ratioStr = `${currentWidth}x${currentHeight}`;
// Dynamic Truncation Logic to preserve Suffix/SKU
const skuPrefix = (project as any).sku ? `${(project as any).sku}_` : "";
const ratioSuffix = `_${ratioStr}`;
const targetExt = mode === 'cmyk' ? '.tif' : '.png';
const MAX_FILENAME_LEN = 65; // Strict limit
const reservedLen = skuPrefix.length + ratioSuffix.length + targetExt.length;
const availableTitleLen = MAX_FILENAME_LEN - reservedLen;
let finalTitle = safeTitle;
if (finalTitle.length > availableTitleLen) {
finalTitle = finalTitle.substring(0, Math.max(10, availableTitleLen)); // Ensure at least 10 chars
}
// Construct Final Name
// Handle duplicates by pre-checking against nameCount map?
// Actually, simple counter logic needs to happen BEFORE full truncation or AFTER?
// Better to rely on unique ratioStr for master assets.
// But if multiple assets map to same ratio (e.g. variants), we need index.
let baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
if (nameCount[baseNameCandidate]) {
nameCount[baseNameCandidate]++;
// Adjust title slightly to accommodate counter?
// Or just append counter and risk going over limit slightly?
// Let's shorten title more if needed.
const countStr = `_${nameCount[baseNameCandidate]}`;
if ((baseNameCandidate.length + countStr.length + targetExt.length) > 70) {
// Shrink title more
const reduceBy = (baseNameCandidate.length + countStr.length + targetExt.length) - 70;
finalTitle = finalTitle.substring(0, finalTitle.length - reduceBy);
baseNameCandidate = `${skuPrefix}${finalTitle}${ratioSuffix}`;
}
baseNameCandidate += countStr;
} else {
nameCount[baseNameCandidate] = 1;
}
const finalFilename = `${baseNameCandidate}${targetExt}`;
// ... Processing buffers ...
let sourceBuffer: Buffer;
// Upscale and enforce exact ratio for master/upscaled types
if (!isPrintReady && ['master', 'upscaled'].includes(asset.type)) {
sourceBuffer = await sharp(assetPath)
.resize(targetWidth, targetHeight, {
kernel: sharp.kernel.lanczos3,
fit: 'cover',
position: 'center'
})
.withMetadata({ density: 300 })
.png({ quality: 100, compressionLevel: 6 })
.toBuffer();
} else {
sourceBuffer = fs.readFileSync(assetPath);
}
if (mode === 'cmyk') {
// TRUE CMYK Conversion
let cmykBuffer: Buffer;
try {
cmykBuffer = await sharp(sourceBuffer)
.pipelineColourspace('rgb16')
.toColourspace('cmyk')
.withMetadata({ density: 300 })
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
.toBuffer();
} catch (cmykError: any) {
console.warn(`[CMYK] Fallback for ${finalFilename}`);
cmykBuffer = await sharp(sourceBuffer)
.withMetadata({ density: 300 })
.tiff({ compression: 'lzw', quality: 100, xres: 300, yres: 300 })
.toBuffer();
}
archive.append(cmykBuffer, { name: `print_ready/${finalFilename}` });
} else {
archive.append(sourceBuffer, { name: `digital_files_rgb/${finalFilename}` });
}
} catch (e) {
console.error(`Processing failed for ${assetPath}:`, e);
// Fallback to original
archive.file(assetPath, { name: `fallback/${path.basename(assetPath)}` });
}
}
}
// Add Guide
if (project.seoData?.printingGuide) {
const guidePath = path.join(STORAGE_ROOT, project.seoData.printingGuide);
if (fs.existsSync(guidePath)) {
archive.file(guidePath, { name: 'Printing_Instructions.txt' });
}
}
await archive.finalize();
}
}
export const archiveService = new ArchiveService();