This commit is contained in:
535
services/archiveService.ts
Normal file
535
services/archiveService.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
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();
|
||||
175
services/etsyAuth.ts
Normal file
175
services/etsyAuth.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import FormData from 'form-data';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const ETSY_KEY_STRING = process.env.ETSY_KEY_STRING || '';
|
||||
// Shared secret is usually not needed for v3 PKCE flows unless specific scopes/endpoints require signing,
|
||||
// but we keep it in env just in case.
|
||||
const ETSY_REDIRECT_URI = 'http://localhost:3001/api/etsy/callback';
|
||||
|
||||
export class EtsyAuthService {
|
||||
|
||||
// Generate PKCE Challenge
|
||||
static generateChallenge() {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
return { codeVerifier, codeChallenge, state };
|
||||
}
|
||||
|
||||
// Get Auth URL
|
||||
static getAuthUrl(codeChallenge: string, state: string, scopes: string[]) {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: ETSY_KEY_STRING,
|
||||
redirect_uri: ETSY_REDIRECT_URI,
|
||||
scope: scopes.join(' '),
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return `https://www.etsy.com/oauth/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Exchange Code for Token
|
||||
static async getAccessToken(code: string, codeVerifier: string) {
|
||||
try {
|
||||
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: ETSY_KEY_STRING,
|
||||
redirect_uri: ETSY_REDIRECT_URI,
|
||||
code: code,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
return response.data; // { access_token, refresh_token, expires_in, etc }
|
||||
} catch (error: any) {
|
||||
console.error('Etsy Token Exchange Error:', error.response?.data || error.message);
|
||||
throw new Error('Failed to exchange code for token');
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh Token
|
||||
static async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: ETSY_KEY_STRING,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Etsy Token Refresh Error:', error.response?.data || error.message);
|
||||
throw new Error('Failed to refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
// Get User/Shop ID
|
||||
static async getSelf(accessToken: string) {
|
||||
try {
|
||||
const response = await axios.get('https://api.etsy.com/v3/application/users/me', {
|
||||
headers: {
|
||||
'x-api-key': ETSY_KEY_STRING,
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
// Response usually contains user_id. From there we can get the shop.
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('getSelf Error:', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getShop(userId: string | number, accessToken: string) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.etsy.com/v3/application/users/${userId}/shops`, {
|
||||
headers: {
|
||||
'x-api-key': ETSY_KEY_STRING,
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
return response.data; // Returns list of shops (usually 1)
|
||||
} catch (error: any) {
|
||||
console.error('getShop Error:', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- LISTING MANAGEMENT (V3) ---
|
||||
|
||||
static async createDraftListing(shopId: string, accessToken: string, data: {
|
||||
title: string,
|
||||
description: string,
|
||||
price: number,
|
||||
quantity: number,
|
||||
who_made: 'i_did' | 'collective' | 'someone_else',
|
||||
when_made: 'made_to_order' | '2020_2025' | '2010_2019' | string,
|
||||
is_supply: boolean,
|
||||
taxonomy_id: number
|
||||
}) {
|
||||
try {
|
||||
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings`, data, {
|
||||
headers: {
|
||||
'x-api-key': ETSY_KEY_STRING,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
return response.data; // { listing_id, ... }
|
||||
} catch (error: any) {
|
||||
console.error('createDraftListing Error:', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadListingImage(shopId: string, listingId: number | string, accessToken: string, imageBuffer: Buffer, filename: string) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageBuffer, { filename });
|
||||
|
||||
const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings/${listingId}/images`, formData, {
|
||||
headers: {
|
||||
'x-api-key': ETSY_KEY_STRING,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
...formData.getHeaders()
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('uploadListingImage Error:', error.response?.data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check and refresh token if needed
|
||||
static async ensureValidToken(shopRecord: any) {
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
if (shopRecord.expiresAt > now + BigInt(60)) {
|
||||
return shopRecord.accessToken;
|
||||
}
|
||||
|
||||
console.log(`[Etsy] Refreshing token for shop ${shopRecord.shopName}`);
|
||||
const tokenData = await this.refreshToken(shopRecord.refreshToken);
|
||||
|
||||
const updated = await prisma.etsyShop.update({
|
||||
where: { id: shopRecord.id },
|
||||
data: {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: BigInt(Math.floor(Date.now() / 1000) + tokenData.expires_in)
|
||||
}
|
||||
});
|
||||
|
||||
return updated.accessToken;
|
||||
}
|
||||
}
|
||||
395
services/geminiService.ts
Normal file
395
services/geminiService.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY as string });
|
||||
|
||||
interface StickerPlan {
|
||||
prompts: string[];
|
||||
characterCore: string;
|
||||
}
|
||||
|
||||
export const geminiService = {
|
||||
/**
|
||||
* The Brain: Analyzes the user's prompt and generates a list of distinct scenarios.
|
||||
* @param originalPrompt The user's raw input
|
||||
* @param count Number of variations to generate
|
||||
* @returns List of prompts and the core character description
|
||||
*/
|
||||
async generateStickerSetPlan(originalPrompt: string, count: number): Promise<StickerPlan> {
|
||||
console.log(`[GeminiService] Planning ${count} sticker variations...`);
|
||||
|
||||
const planningPrompt = `
|
||||
You are an expert Sticker Set Planner.
|
||||
Analyze this sticker prompt: "${originalPrompt}"
|
||||
|
||||
1. Extract the CORE VISUAL IDENTITY (Character description, style, colors, clothes).
|
||||
This must be preserved EXACTLY in every variation.
|
||||
|
||||
2. Generate ${count} DISTINCT variation prompts.
|
||||
Each variation must be a different pose, emotion, or action suitable for a sticker pack.
|
||||
(e.g., Happy, Sad, Thinking, Coffee, Running, Sleeping, Winking, etc.)
|
||||
|
||||
Output JSON ONLY:
|
||||
{
|
||||
"characterCore": "The extracted core visual description...",
|
||||
"variations": [
|
||||
"Full prompt for variation 1...",
|
||||
"Full prompt for variation 2..."
|
||||
]
|
||||
}
|
||||
|
||||
Rules for Variations:
|
||||
- Combine the "Character Core" with the new pose/action.
|
||||
- Ensure the output is a FULL stable diffusion style prompt (tags).
|
||||
- Keep the style tags consistent.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.0-flash",
|
||||
contents: [{ text: planningPrompt }],
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
} as any
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text) throw new Error("No response from Gemini");
|
||||
|
||||
const json = JSON.parse(text);
|
||||
|
||||
let prompts = json.variations || [];
|
||||
|
||||
// SECURITY: Force array to be string[]
|
||||
if (!Array.isArray(prompts)) {
|
||||
prompts = [originalPrompt];
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Ensure we have exactly 'count' items
|
||||
// If AI returns fewer, we pad with the existing ones (round-robin)
|
||||
if (prompts.length < count) {
|
||||
console.warn(`[GeminiService] AI returned only ${prompts.length}/${count} variations. Padding...`);
|
||||
while (prompts.length < count) {
|
||||
// pushing a random existing prompt to fill the gap
|
||||
prompts.push(prompts[prompts.length % prompts.length]);
|
||||
}
|
||||
}
|
||||
|
||||
// If AI determines more are needed, trim? No, extra is fine, but let's slice just in case UI expects N
|
||||
// Actually, extra is bonus. But user paid for N? We usually don't charge per variant in this model, but batch cost.
|
||||
|
||||
return {
|
||||
prompts: prompts.slice(0, count), // Ensure exact count
|
||||
characterCore: json.characterCore || originalPrompt
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Gemini Planning Error:", error);
|
||||
// Fallback: Just repeat the prompt if planning fails? Or error out?
|
||||
// For now, return the original prompt repeated to avoid crash, but log error.
|
||||
return {
|
||||
prompts: Array(count).fill(originalPrompt),
|
||||
characterCore: originalPrompt
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* X-Ray: Updates the user on the visual DNA, strategic gaps, and superior prompt.
|
||||
*/
|
||||
async analyzeCompetitorProduct(params: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageBase64: string;
|
||||
apiKey?: string;
|
||||
}): Promise<any> {
|
||||
console.log(`[GeminiService] Running Competitor X-Ray Analysis...`);
|
||||
const { title, description, imageBase64, apiKey } = params;
|
||||
|
||||
// Strip header if present to avoid 500 errors
|
||||
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
// Use the specialized Vision model for analysis
|
||||
const MODEL_NAME = "gemini-2.0-flash";
|
||||
|
||||
const analysisPrompt = `
|
||||
You are an elite E-commerce Strategist and Art Director.
|
||||
Analyze this competitor product to help us create something SUPERIOR.
|
||||
|
||||
PRODUCT CONTEXT:
|
||||
Title: "${title}"
|
||||
Description: "${description.substring(0, 500)}..."
|
||||
|
||||
TASK:
|
||||
1. VISUAL DNA: Deconstruct the aesthetic formula (Color palette info, composition style, emotional triggers).
|
||||
2. SENTIMENT GAP: Identify what is missing or could be improved (e.g., "Lighting is too flat", "Composition is cluttered").
|
||||
3. SUPERIOR PROMPT: Write a "Nano Banana" style stable diffusion prompt to generate a version of this product that is 10x BETTER.
|
||||
- Must use (weighted:1.2) tags.
|
||||
- Must include quality boosters.
|
||||
- Must solve the identified gaps.
|
||||
|
||||
OUTPUT JSON ONLY:
|
||||
{
|
||||
"visualDna": ["Tag 1", "Tag 2", "Hex Colors", "Composition Rule"],
|
||||
"sentimentGap": "Brief strategic analysis of weaknesses...",
|
||||
"superiorPrompt": "(masterpiece:1.4), ...",
|
||||
"gapAnalysis": "Detailed explanation of why the new prompt is better"
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
|
||||
|
||||
const response = await client.models.generateContent({
|
||||
model: MODEL_NAME,
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: analysisPrompt },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/jpeg",
|
||||
data: cleanBase64
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
} as any
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text) throw new Error("No response from AI");
|
||||
|
||||
return JSON.parse(text);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Gemini X-Ray Error:", error);
|
||||
throw new Error("Failed to analyze product: " + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Neuro-Scorecard: Analyzes an image for commercial potential using neuro-marketing principles.
|
||||
*/
|
||||
async analyzeImageNeuroScore(params: {
|
||||
imageBase64: string;
|
||||
apiKey?: string;
|
||||
}): Promise<any> {
|
||||
console.log(`[GeminiService] Running Neuro-Scorecard Analysis...`);
|
||||
const { imageBase64, apiKey } = params;
|
||||
// Strip header if present
|
||||
const cleanBase64 = imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
// Use Vision model for image analysis
|
||||
const MODEL_NAME = "gemini-2.0-flash";
|
||||
|
||||
const analysisPrompt = `
|
||||
You are a Neuromarketing Expert and Senior Art Director.
|
||||
Score this image based on its potential to sell on Etsy.
|
||||
|
||||
ANALYZE THESE DIMENSIONS (Score 0-10):
|
||||
1. **Dopamine Hit**: Does it create immediate excitement/craving?
|
||||
2. **Serotonin Flow**: Does it evoke trust, calm, or belonging?
|
||||
3. **Cognitive Ease**: Is it easy to process instantly? (High score = distinct subject, clear lighting).
|
||||
4. **Commercial Fit**: Does it look like a high-end product vs. an amateur photo?
|
||||
|
||||
CRITICAL INSTRUCTION FOR "IMPROVEMENTS":
|
||||
- YOU MUST PROVIDE EXACTLY 2 SPECIFIC IMPROVEMENTS PER CATEGORY.
|
||||
- EVEN IF THE SCORE IS 10/10, suggest experimental tweaks.
|
||||
- Do NOT give generic advice like "increase contrast".
|
||||
- BE SPECIFIC to the image content. Mention specific objects, colors, or areas.
|
||||
|
||||
⚠️ FORBIDDEN SUGGESTIONS (NEVER SUGGEST THESE):
|
||||
- Mockups (e.g., "show this in a living room", "place on a wall")
|
||||
- Context/environment changes (e.g., "add a frame", "show as wall art")
|
||||
- Marketing/presentation ideas (e.g., "include in a bundle", "show lifestyle shot")
|
||||
|
||||
✅ ALLOWED SUGGESTIONS (ONLY THESE TYPES):
|
||||
- Color adjustments (saturation, hue, warmth, vibrancy)
|
||||
- Lighting changes (exposure, shadows, highlights, contrast)
|
||||
- Composition tweaks (crop, reframe, balance, focal point)
|
||||
- Detail enhancements (sharpness, texture, remove artifacts)
|
||||
- Style refinements (artistic filters, mood adjustments, grain)
|
||||
|
||||
- Example GOOD: "Increase the saturation of the red vase to make it pop."
|
||||
- Example GOOD: "Add subtle vignette to draw eye to center."
|
||||
- Example BAD: "Put this in a living room mockup." ❌
|
||||
- NEVER RETURN AN EMPTY ARRAY.
|
||||
|
||||
OUTPUT JSON ONLY:
|
||||
{
|
||||
"scores": {
|
||||
"dopamine": 8.5,
|
||||
"serotonin": 7.0,
|
||||
"cognitiveEase": 9.0,
|
||||
"commercialFit": 6.5
|
||||
},
|
||||
"feedback": [
|
||||
"Positive point 1",
|
||||
"Positive point 2"
|
||||
],
|
||||
"improvements": {
|
||||
"dopamine": ["Specific fix 1", "Specific fix 2"],
|
||||
"serotonin": ["Specific fix 1", "Specific fix 2"],
|
||||
"cognitiveEase": ["Specific fix 1", "Specific fix 2"],
|
||||
"commercialFit": ["Specific fix 1", "Specific fix 2"]
|
||||
},
|
||||
"prediction": "High/Medium/Low Conversion Potential"
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
|
||||
|
||||
const response = await client.models.generateContent({
|
||||
model: MODEL_NAME,
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: analysisPrompt },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "image/jpeg",
|
||||
data: cleanBase64
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
} as any
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
if (!text) throw new Error("No response from AI");
|
||||
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// NORMALIZE DATA: Check if AI returned old format or missing keys
|
||||
if (!data.improvements) {
|
||||
console.warn("[GeminiService] AI returned old format, normalizing...");
|
||||
data.improvements = {
|
||||
dopamine: [],
|
||||
serotonin: [],
|
||||
cognitiveEase: [],
|
||||
commercialFit: data.criticalImprovements || [] // Fallback to old field
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure all keys exist
|
||||
const defaults = { dopamine: [], serotonin: [], cognitiveEase: [], commercialFit: [] };
|
||||
data.improvements = { ...defaults, ...data.improvements };
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Gemini Neuro-Score Error:", error);
|
||||
throw new Error("Failed to score image: " + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Web Research: Uses Google Search Grounding to extract metadata from a URL.
|
||||
* Bypasses local IP blocking by using Google's servers.
|
||||
*/
|
||||
async performWebResearch(url: string, apiKey?: string): Promise<{ title: string, description: string, image: string }> {
|
||||
console.log(`[GeminiService] Performing Google Search Grounding for: ${url}`);
|
||||
|
||||
const researchPrompt = `
|
||||
Analyze this product URL: "${url}"
|
||||
|
||||
TASK:
|
||||
Extract the following metadata from this product page:
|
||||
1. Product Title (Exact full title)
|
||||
2. Product Description (First 2-3 sentences summary)
|
||||
3. Main Product Image URL (Direct link to the highest resolution image, must be a full URL starting with https://)
|
||||
|
||||
IMPORTANT: You MUST return valid JSON. Do not include any text before or after the JSON.
|
||||
|
||||
OUTPUT FORMAT (JSON ONLY):
|
||||
{
|
||||
"title": "Product Title Here",
|
||||
"description": "Product Description Here...",
|
||||
"image": "https://full-url-to-image.jpg"
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const client = apiKey ? new GoogleGenAI({ apiKey }) : ai;
|
||||
|
||||
// First try with Google Search grounding
|
||||
let response;
|
||||
try {
|
||||
response = await client.models.generateContent({
|
||||
model: "gemini-2.0-flash",
|
||||
contents: [{ text: researchPrompt }],
|
||||
tools: [{
|
||||
googleSearch: {}
|
||||
}],
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
}
|
||||
} as any);
|
||||
} catch (searchError: any) {
|
||||
console.warn("[GeminiService] Google Search grounding failed, trying without:", searchError.message);
|
||||
// Fallback: Try without search grounding
|
||||
response = await client.models.generateContent({
|
||||
model: "gemini-2.0-flash",
|
||||
contents: [{ text: researchPrompt }],
|
||||
config: {
|
||||
responseMimeType: "application/json"
|
||||
} as any
|
||||
});
|
||||
}
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
console.log("[GeminiService] Raw research response:", text?.substring(0, 200));
|
||||
|
||||
if (!text) throw new Error("No response from AI Research");
|
||||
|
||||
// Try to parse JSON, handle cases where response might have extra text
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
// Try to extract JSON from text
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
data = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
throw new Error("Could not parse JSON from response");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!data.title) {
|
||||
console.warn("[GeminiService] Missing title in response, extracting from URL...");
|
||||
// Extract title from URL as fallback
|
||||
const urlParts = url.split('/');
|
||||
const slug = urlParts.find(p => p.length > 10 && !p.includes('.'));
|
||||
data.title = slug ? slug.replace(/-/g, ' ') : "Unknown Product";
|
||||
}
|
||||
|
||||
if (!data.image || !data.image.startsWith('http')) {
|
||||
console.warn("[GeminiService] Invalid or missing image URL");
|
||||
data.image = "";
|
||||
}
|
||||
|
||||
console.log(`[GeminiService] Research Success: ${data.title}`);
|
||||
return data;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Gemini Research Error:", error);
|
||||
throw new Error("Failed to research url: " + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
192
services/stickerSheetService.ts
Normal file
192
services/stickerSheetService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Paper Dimensions (at 300 DPI)
|
||||
const PAPER_SIZES: Record<string, { width: number, height: number }> = {
|
||||
'A4': { width: 2480, height: 3508 }, // 210mm x 297mm
|
||||
'Letter': { width: 2550, height: 3300 }, // 8.5in x 11in
|
||||
'A5': { width: 1748, height: 2480 } // 148mm x 210mm
|
||||
};
|
||||
|
||||
export const stickerSheetService = {
|
||||
|
||||
/**
|
||||
* Add a white border and optional cut line to an image.
|
||||
* @param inputPath Path to the source image (sticker).
|
||||
* @param borderWidth Width of the white contour in pixels (default 40).
|
||||
*/
|
||||
async addCutContour(inputPath: string, borderWidth: number = 40): Promise<Buffer> {
|
||||
// 1. Load Image
|
||||
const image = sharp(inputPath);
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata");
|
||||
|
||||
// 2. Create Mask (Alpha Channel)
|
||||
const alpha = await image.clone()
|
||||
.toColourspace('b-w')
|
||||
.ensureAlpha()
|
||||
.extractChannel(3) // Get alpha channel
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// 3. Dilate Mask to create Border Shape
|
||||
// Proper dilation requires morphological operations which Sharp doesn't support natively well for complex shapes without external libs.
|
||||
// TRICK: We will blur the alpha channel and threshold it to simulate dilation/expansion.
|
||||
const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } })
|
||||
.blur(borderWidth) // Spread the alpha
|
||||
.threshold(10) // Binarize to create hard edge
|
||||
.toBuffer();
|
||||
|
||||
// 4. Create White Silhouette using the expanded mask
|
||||
const whiteBackground = await sharp({
|
||||
create: {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
||||
}
|
||||
})
|
||||
.joinChannel(expandedMask) // Apply mask
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// 5. Composite Original Image ON TOP of White Silhouette
|
||||
return sharp(whiteBackground)
|
||||
.composite([{ input: inputPath }])
|
||||
.withMetadata({ density: 300 }) // Enforce 300 DPI
|
||||
.png()
|
||||
.toBuffer();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a BLACKOUT mask for Cricut Cut Lines.
|
||||
* Returns a binary verification image (Black shape on transparent background).
|
||||
*/
|
||||
async generateCutMask(inputPath: string, borderWidth: number = 40): Promise<Buffer> {
|
||||
// 1. Load Image
|
||||
const image = sharp(inputPath);
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error("Invalid image metadata");
|
||||
|
||||
// 2. Extract Alpha
|
||||
const alpha = await image.clone()
|
||||
.toColourspace('b-w')
|
||||
.ensureAlpha()
|
||||
.extractChannel(3)
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// 3. Dilate (Same logic as addCutContour to match perfectly)
|
||||
const expandedMask = await sharp(alpha, { raw: { width: metadata.width, height: metadata.height, channels: 1 } })
|
||||
.blur(borderWidth)
|
||||
.threshold(10)
|
||||
.toBuffer();
|
||||
|
||||
// 4. Create BLACK Silhouette
|
||||
return sharp({
|
||||
create: {
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 } // BLACK
|
||||
}
|
||||
})
|
||||
.joinChannel(expandedMask) // Apply same mask
|
||||
.withMetadata({ density: 300 }) // Enforce 300 DPI
|
||||
.png()
|
||||
.toBuffer();
|
||||
},
|
||||
|
||||
/**
|
||||
* Arrange multiple stickers onto a single sheet.
|
||||
* @param stickerPaths Array of absolute paths to sticker images.
|
||||
* @param options Configuration for the sheet (target dimensions, paper size name)
|
||||
*/
|
||||
async generateSheet(stickerPaths: string[], options: { width: number, height: number, paperSizeName?: string }): Promise<Buffer> {
|
||||
const { width, height } = options;
|
||||
|
||||
// Target 4-5 columns for A4/Letter at 2500px width.
|
||||
// If width is ~5000px (High Res A4), we still want ~4-5 columns?
|
||||
// Or do we want more stickers if we have them?
|
||||
// Usually a sticker sheet has a fixed number of stickers.
|
||||
// Let's assume we want a standard grid.
|
||||
// Let's base sticker size on WIDTH.
|
||||
// 4 cols + 5 gaps = width.
|
||||
// stickerW * 4 + gap * 5 = width.
|
||||
// let gap = stickerW / 10.
|
||||
// stickerW * 4 + stickerW/2 = width => 4.5 stickerW = width.
|
||||
|
||||
const COLS = 4;
|
||||
// Calculation:
|
||||
// Width = COLS * StickerW + (COLS + 1) * GAP
|
||||
// Let GAP = StickerW * 0.1
|
||||
// Width = 4 * S + 5 * 0.1 * S = 4.5 S
|
||||
const stickerWidth = Math.floor(width / (COLS + (COLS + 1) * 0.1));
|
||||
const gap = Math.floor(stickerWidth * 0.1);
|
||||
|
||||
console.log(`[StickerService] Generating Sheet ${width}x${height}. Sticker W: ${stickerWidth}, Gap: ${gap}`);
|
||||
|
||||
const composites: sharp.OverlayOptions[] = [];
|
||||
|
||||
let currentX = gap;
|
||||
let currentY = gap;
|
||||
let maxHeightInRow = 0;
|
||||
|
||||
for (const stickerPath of stickerPaths) {
|
||||
let stickerBuffer;
|
||||
try {
|
||||
stickerBuffer = await this.addCutContour(stickerPath, Math.floor(stickerWidth * 0.05)); // Scale border too
|
||||
} catch (e) {
|
||||
console.error(`Failed to process sticker ${stickerPath}`, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resize to target width
|
||||
const resizedSticker = await sharp(stickerBuffer)
|
||||
.resize({ width: stickerWidth })
|
||||
.toBuffer();
|
||||
|
||||
const meta = await sharp(resizedSticker).metadata();
|
||||
const w = meta.width || stickerWidth;
|
||||
const h = meta.height || stickerWidth;
|
||||
|
||||
// Check if fits in current row
|
||||
if (currentX + w + gap > width) {
|
||||
// Next Row
|
||||
currentX = gap;
|
||||
currentY += maxHeightInRow + gap;
|
||||
maxHeightInRow = 0;
|
||||
}
|
||||
|
||||
// Check if fits in page (vertical)
|
||||
if (currentY + h + gap > height) {
|
||||
console.warn("Sheet full, skipping remaining stickers");
|
||||
break;
|
||||
}
|
||||
|
||||
composites.push({
|
||||
input: resizedSticker,
|
||||
top: currentY,
|
||||
left: currentX
|
||||
});
|
||||
|
||||
currentX += w + gap;
|
||||
if (h > maxHeightInRow) maxHeightInRow = h;
|
||||
}
|
||||
|
||||
// Create Sheet Canvas
|
||||
return sharp({
|
||||
create: {
|
||||
width: width,
|
||||
height: height,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 } // Transparent
|
||||
}
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
};
|
||||
180
services/usageService.ts
Normal file
180
services/usageService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const prisma = new PrismaClient() as any;
|
||||
|
||||
// COST CONSTANTS (USD)
|
||||
const COSTS = {
|
||||
GENERATE_PROMPT: 0.001, // Gemini Pro Text
|
||||
GENERATE_MASTER: 0.040, // Imagen 3 (High Res)
|
||||
GENERATE_VARIANT: 0.020, // Imagen 3 (Standard/Fast)
|
||||
GENERATE_MOCKUP: 0.020, // Imagen 3 (Standard/Fast)
|
||||
REFINE_PROJECT: 0.040, // Imagen 3 (High Res) - treat same as Master
|
||||
MOCKUP_REMOVAL: 0.005 // Background Removal (Hypothetical)
|
||||
};
|
||||
|
||||
// CREDIT PRICES
|
||||
const PRICES = {
|
||||
GENERATE_PROMPT: 1,
|
||||
GENERATE_MASTER: 10,
|
||||
GENERATE_VARIANT: 5,
|
||||
GENERATE_MOCKUP: 5,
|
||||
REFINE_PROJECT: 10, // Same as Master
|
||||
MOCKUP_REMOVAL: 2
|
||||
};
|
||||
|
||||
export type ActionType = keyof typeof COSTS;
|
||||
|
||||
|
||||
// CACHE: In-memory store for pricing config to reduce DB hits
|
||||
// Default TTL: 60 seconds
|
||||
let configCache: { [key: string]: number } | null = null;
|
||||
let cacheExpiry = 0;
|
||||
const CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
async function getPricing(action: ActionType): Promise<{ cost: number, credits: number }> {
|
||||
const now = Date.now();
|
||||
|
||||
// 1. Refresh Cache if expired
|
||||
if (!configCache || now > cacheExpiry) {
|
||||
try {
|
||||
const configs = await prisma.systemConfig.findMany();
|
||||
configCache = {};
|
||||
// Populate cache
|
||||
configs.forEach((c: any) => {
|
||||
const val = parseFloat(c.value);
|
||||
if (!isNaN(val)) {
|
||||
configCache![c.key] = val;
|
||||
}
|
||||
});
|
||||
cacheExpiry = now + CACHE_TTL_MS;
|
||||
// console.log("[UsageService] Pricing cache refreshed.");
|
||||
} catch (e) {
|
||||
console.error("[UsageService] Failed to fetch system config, using defaults.", e);
|
||||
// If DB fails, fallback to empty cache (which triggers defaults below)
|
||||
if (!configCache) configCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Resolve Values (DB > Default)
|
||||
// Keys in DB: COST_GENERATE_MASTER, PRICE_GENERATE_MASTER
|
||||
const costKey = `COST_${action}`;
|
||||
const priceKey = `PRICE_${action}`;
|
||||
|
||||
const cost = (configCache && configCache[costKey] !== undefined)
|
||||
? configCache[costKey]
|
||||
: COSTS[action];
|
||||
|
||||
const credits = (configCache && configCache[priceKey] !== undefined)
|
||||
? configCache[priceKey]
|
||||
: PRICES[action];
|
||||
|
||||
return { cost, credits };
|
||||
}
|
||||
|
||||
export const usageService = {
|
||||
/**
|
||||
* Deducts credits using DYNAMIC pricing.
|
||||
*/
|
||||
async deductCredits(userId: string, action: ActionType) {
|
||||
const { cost, credits } = await getPricing(action);
|
||||
|
||||
return await prisma.$transaction(async (tx: any) => {
|
||||
const user = await tx.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
// ADMIN OVERRIDE: Unlimited Credits
|
||||
// God Mode: Admins bypass all checks.
|
||||
if (user.role === 'ADMIN' || user.role === 'VIP') {
|
||||
console.log(`[UsageService] ⚡ GOD MODE: User is ${user.role}, bypassing credit deduction.`);
|
||||
return user;
|
||||
}
|
||||
|
||||
if (user.credits < credits) {
|
||||
// Determine if we should show the 'cost' in error message
|
||||
throw new Error(`Insufficient credits for ${action}. Needed: ${credits}, Balance: ${user.credits}`);
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
credits: { decrement: credits },
|
||||
totalCost: { increment: cost }
|
||||
}
|
||||
});
|
||||
|
||||
await tx.usageLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action,
|
||||
cost,
|
||||
credits
|
||||
}
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to get current price for UI (without deducting)
|
||||
*/
|
||||
async getActionPrice(action: ActionType) {
|
||||
return await getPricing(action);
|
||||
},
|
||||
|
||||
async recordPurchase(userId: string, amountUSD: number, creditsGiven: number) {
|
||||
return await prisma.$transaction(async (tx: any) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
credits: { increment: creditsGiven },
|
||||
totalRevenue: { increment: amountUSD }
|
||||
}
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: amountUSD,
|
||||
credits: creditsGiven,
|
||||
type: "PURCHASE"
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Validates a Gemini API Key by making a minimal test call.
|
||||
*/
|
||||
async validateApiKey(apiKey: string): Promise<{ valid: boolean, error?: string, code?: string }> {
|
||||
if (!apiKey || apiKey.length < 20) return { valid: false, error: "API Key is too short or missing.", code: "INVALID_FORMAT" };
|
||||
|
||||
try {
|
||||
const genAI = new GoogleGenAI({ apiKey });
|
||||
// Minimal token generation to test validity & quota
|
||||
await genAI.models.generateContent({
|
||||
model: "gemini-3-flash-preview", // Updated to a confirmed available model
|
||||
contents: [{ role: "user", parts: [{ text: "Hi" }] }],
|
||||
config: { maxOutputTokens: 1 }
|
||||
});
|
||||
|
||||
return { valid: true };
|
||||
} catch (error: any) {
|
||||
console.warn("[UsageService] API Key Validation Failed:", error.message);
|
||||
const msg = error.message || "";
|
||||
|
||||
if (msg.includes("403") || msg.includes("API key not valid")) {
|
||||
return { valid: false, error: "Invalid API Key. Please check characters.", code: "INVALID_KEY" };
|
||||
}
|
||||
if (msg.includes("429") || msg.includes("quota")) {
|
||||
return { valid: false, error: "API Key Quota Exceeded. You may need to enable billing.", code: "QUOTA_EXCEEDED" };
|
||||
}
|
||||
if (msg.includes("400")) {
|
||||
return { valid: false, error: "Bad Request. Key might be malformed.", code: "BAD_REQUEST" };
|
||||
}
|
||||
|
||||
return { valid: false, error: "Validator Error: " + msg, code: "UNKNOWN_ERROR" };
|
||||
}
|
||||
}
|
||||
};
|
||||
96
services/xrayService.ts
Normal file
96
services/xrayService.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import { geminiService } from './geminiService.js';
|
||||
|
||||
interface XRayResult {
|
||||
success: boolean;
|
||||
data?: {
|
||||
metadata: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
};
|
||||
analysis: {
|
||||
visualDna: string[];
|
||||
sentimentGap: string;
|
||||
superiorPrompt: string;
|
||||
gapAnalysis: string;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const xrayService = {
|
||||
/**
|
||||
* Scrapes a product URL and performs deep AI analysis
|
||||
*/
|
||||
async analyzeProduct(url: string, apiKey?: string): Promise<XRayResult> {
|
||||
try {
|
||||
console.log(`[X-Ray] Analyzing: ${url}`);
|
||||
|
||||
// 1. WEB RESEARCH (Google Search Grounding)
|
||||
// Replaces legacy Axios scraping which gets blocked (403/422) by Etsy
|
||||
const researchResult = await geminiService.performWebResearch(url, apiKey);
|
||||
|
||||
if (!researchResult.title) {
|
||||
throw new Error("AI Retrieval failed to extract product title. The URL may not be accessible or the product page structure is unsupported.");
|
||||
}
|
||||
|
||||
if (!researchResult.image) {
|
||||
throw new Error("AI Retrieval could not find a product image URL. Please ensure the URL points to a valid product page.");
|
||||
}
|
||||
|
||||
console.log("[X-Ray] Metadata extracted via Google:", researchResult.title);
|
||||
|
||||
// 2. Perform AI Analysis (Visual + Text)
|
||||
// We pass the image URL directly if public, OR we might need to download it first base64.
|
||||
// For stability, let's download the image to base64 buffer
|
||||
// Note: Since we have the direct image URL now (likely CDN), downloadImage should work better
|
||||
// than scraping the main page.
|
||||
const imageBuffer = await this.downloadImage(researchResult.image);
|
||||
const imageBase64 = imageBuffer.toString('base64');
|
||||
|
||||
const metadata = {
|
||||
title: researchResult.title,
|
||||
description: researchResult.description,
|
||||
image: researchResult.image,
|
||||
tags: [] // Search might not return tags, optional
|
||||
};
|
||||
|
||||
const analysis = await geminiService.analyzeCompetitorProduct({
|
||||
title: researchResult.title,
|
||||
description: researchResult.description,
|
||||
imageBase64: imageBase64,
|
||||
apiKey
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
metadata,
|
||||
analysis
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[X-Ray] Error:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "Failed to analyze product"
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async downloadImage(url: string): Promise<Buffer> {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.etsy.com/'
|
||||
}
|
||||
});
|
||||
return Buffer.from(response.data, 'binary');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user