main
Some checks failed
Deploy Backend / deploy (push) Has been cancelled

This commit is contained in:
2026-02-05 01:29:22 +03:00
parent ae24c17f50
commit 80dcf4d04a
30 changed files with 14275 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
name: Deploy Backend
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Create .env file
run: |
echo "DATABASE_URL=\"file:/app/data/dev.db\"" > .env
echo "STORAGE_PATH=\"/app/data/storage\"" >> .env
echo "JWT_SECRET=\"${{ secrets.JWT_SECRET }}\"" >> .env
echo "GEMINI_API_KEY=\"${{ secrets.GEMINI_API_KEY }}\"" >> .env
echo "ETSY_KEY_STRING=\"${{ secrets.ETSY_KEY_STRING }}\"" >> .env
echo "ETSY_SHARED_SECRET=\"${{ secrets.ETSY_SHARED_SECRET }}\"" >> .env
echo "BETA_MODE=\"true\"" >> .env
- name: Build and Deploy Docker
run: |
docker build -t backend-digicraft .
docker stop backend-digicraft-container || true
docker rm backend-digicraft-container || true
docker run -d \
--name backend-digicraft-container \
-p 1805:3001 \
--restart always \
--network gitea-server_gitea \
-v $(pwd)/data:/app/data \
--env-file .env \
backend-digicraft

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# Build stage
FROM node:20-slim AS builder
WORKDIR /app
# Install openssl for Prisma
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY prisma ./prisma/
RUN npm install
RUN npx prisma generate
COPY . .
# In this specific project, we run via tsx directly
# If a build step is added later, it should happen here
# Production stage
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY . .
# Ensure data directory exists for SQLite
RUN mkdir -p /app/data
EXPOSE 3001
CMD ["npx", "tsx", "index.ts"]

34
clean_orphaned_assets.ts Normal file
View File

@@ -0,0 +1,34 @@
import { PrismaClient } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
const STORAGE_ROOT = process.env.STORAGE_PATH || path.join(__dirname, '..', 'storage');
async function cleanOrphanedAssets(projectId?: string) {
const where = projectId ? { projectId } : {};
const assets = await prisma.asset.findMany({ where });
let cleaned = 0;
for (const a of assets) {
const fullPath = path.join(STORAGE_ROOT, a.path);
if (!fs.existsSync(fullPath)) {
console.log('🗑️ Deleting orphaned record:', a.type, a.path);
await prisma.asset.delete({ where: { id: a.id } });
cleaned++;
}
}
console.log(`\n✅ Cleaned ${cleaned} orphaned asset records.`);
await prisma.$disconnect();
}
// Run with optional project ID
const projectId = process.argv[2];
cleanOrphanedAssets(projectId);

43
create_admin.ts Normal file
View File

@@ -0,0 +1,43 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function createAdmin() {
const email = 'admin@digicraft.app';
const password = 'adminpassword'; // Stronger password
const role = 'ADMIN';
console.log(`Creating Admin User: ${email}`);
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
try {
const user = await prisma.user.upsert({
where: { email },
update: {
passwordHash,
role
},
create: {
email,
passwordHash,
role
}
});
console.log("✅ Admin User Created Successfully!");
console.log(`📧 Email: ${email}`);
console.log(`🔑 Password: ${password}`);
console.log(`🛡️ Role: ${user.role}`);
} catch (error) {
console.error("❌ Error creating admin:", error);
} finally {
await prisma.$disconnect();
}
}
createAdmin();

6099
index.ts Normal file

File diff suppressed because it is too large Load Diff

5166
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "digicraft",
"version": "16.5.0",
"description": "DigiCraft - AI-Powered Digital Product Creation Platform",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "npx tsx index.ts",
"start": "node --import tsx index.ts",
"db": "npx prisma studio",
"db:push": "npx prisma db push",
"db:migrate": "npx prisma migrate deploy",
"db:generate": "npx prisma generate"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@google/genai": "^1.34.0",
"@prisma/client": "^5.10.0",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.0.3",
"@types/sharp": "^0.31.1",
"@types/uuid": "^10.0.0",
"archiver": "^7.0.1",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"google-auth-library": "^10.5.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"prisma": "^5.10.0",
"sharp": "^0.34.5",
"sqlite3": "^5.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/multer": "^2.0.0"
}
}

View File

@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"niche" TEXT NOT NULL,
"productType" TEXT NOT NULL,
"creativity" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Asset" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"path" TEXT NOT NULL,
"prompt" TEXT,
"meta" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SeoData" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"keywords" TEXT NOT NULL,
"printingGuide" TEXT NOT NULL,
"suggestedPrice" TEXT NOT NULL,
CONSTRAINT "SeoData_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "BrandDna" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"referenceIds" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "SeoData_projectId_key" ON "SeoData"("projectId");
-- CreateIndex
CREATE UNIQUE INDEX "BrandDna_name_key" ON "BrandDna"("name");

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the `BrandDna` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "BrandDna";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "BrandProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"referencePaths" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "BrandProfile_name_key" ON "BrandProfile"("name");

View File

@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_BrandProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"referencePaths" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
CONSTRAINT "BrandProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_BrandProfile" ("createdAt", "id", "name", "referencePaths") SELECT "createdAt", "id", "name", "referencePaths" FROM "BrandProfile";
DROP TABLE "BrandProfile";
ALTER TABLE "new_BrandProfile" RENAME TO "BrandProfile";
CREATE UNIQUE INDEX "BrandProfile_name_key" ON "BrandProfile"("name");
CREATE TABLE "new_Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"niche" TEXT NOT NULL,
"productType" TEXT NOT NULL,
"creativity" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Project" ("createdAt", "creativity", "id", "niche", "productType", "status", "updatedAt") SELECT "createdAt", "creativity", "id", "niche", "productType", "status", "updatedAt" FROM "Project";
DROP TABLE "Project";
ALTER TABLE "new_Project" RENAME TO "Project";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

View File

@@ -0,0 +1,114 @@
-- AlterTable
ALTER TABLE "SeoData" ADD COLUMN "jsonLd" TEXT;
-- CreateTable
CREATE TABLE "EtsyShop" (
"id" TEXT NOT NULL PRIMARY KEY,
"shopId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"shopName" TEXT NOT NULL,
"accessToken" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"expiresAt" BIGINT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "EtsyShop_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "UsageLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"cost" REAL NOT NULL,
"credits" INTEGER NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UsageLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Transaction" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"amount" REAL NOT NULL,
"credits" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL,
"description" TEXT,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"niche" TEXT NOT NULL,
"productType" TEXT NOT NULL,
"creativity" TEXT NOT NULL,
"aspectRatio" TEXT NOT NULL DEFAULT '3:4',
"useExactReference" BOOLEAN NOT NULL DEFAULT false,
"status" TEXT NOT NULL DEFAULT 'draft',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Project" ("createdAt", "creativity", "id", "niche", "productType", "status", "updatedAt", "userId") SELECT "createdAt", "creativity", "id", "niche", "productType", "status", "updatedAt", "userId" FROM "Project";
DROP TABLE "Project";
ALTER TABLE "new_Project" RENAME TO "Project";
CREATE TABLE "new_Asset" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"path" TEXT NOT NULL,
"prompt" TEXT,
"meta" TEXT,
"quality" TEXT NOT NULL DEFAULT 'DRAFT',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Asset" ("createdAt", "id", "meta", "path", "projectId", "prompt", "type") SELECT "createdAt", "id", "meta", "path", "projectId", "prompt", "type" FROM "Asset";
DROP TABLE "Asset";
ALTER TABLE "new_Asset" RENAME TO "Asset";
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"googleId" TEXT,
"avatar" TEXT,
"role" TEXT NOT NULL DEFAULT 'USER',
"credits" INTEGER NOT NULL DEFAULT 300,
"plan" TEXT NOT NULL DEFAULT 'FREE',
"betaAccess" BOOLEAN NOT NULL DEFAULT true,
"apiKey" TEXT,
"etsyShopName" TEXT,
"etsyShopLink" TEXT,
"etsyShopLogo" TEXT,
"paymentMethod" TEXT,
"subscriptionId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"termsAcceptedAt" DATETIME,
"kvkkAcceptedAt" DATETIME,
"totalRevenue" REAL NOT NULL DEFAULT 0.0,
"totalCost" REAL NOT NULL DEFAULT 0.0
);
INSERT INTO "new_User" ("createdAt", "email", "id", "passwordHash", "role") SELECT "createdAt", "email", "id", "passwordHash", "role" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- CreateIndex
CREATE UNIQUE INDEX "EtsyShop_shopId_key" ON "EtsyShop"("shopId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

154
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,154 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Project {
id String @id @default(uuid())
niche String
productType String
creativity String
aspectRatio String @default("3:4") // 1:1, 3:4, 4:5, 16:9, 9:16, etc.
useExactReference Boolean @default(false) // Strict Composition Mode
status String @default("draft") // draft, generated, finalized
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sku String? // e.g. "WLR005"
config String? // JSON: { isStickerSet, setSize, basePrompt, characterCore }
assets Asset[]
seoData SeoData?
userId String?
user User? @relation(fields: [userId], references: [id])
}
model Asset {
id String @id @default(uuid())
projectId String
type String // master, mockup, reference, revision
path String // Local file path relative to /storage
prompt String? // The prompt that generated this asset
meta String? // JSON string for extra metadata (scenario name, etc.)
quality String @default("DRAFT") // DRAFT, MASTER, UPSCALED
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
}
model SeoData {
id String @id @default(uuid())
projectId String @unique
title String
description String
keywords String // Stored as comma separated string or JSON
printingGuide String
suggestedPrice String
jsonLd String? // Structured Data (Schema.org)
attributes String? // JSON String: { primaryColor, occasion, etc. }
categoryPath String? // Suggested Category Path
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
}
model EtsyShop {
id String @id @default(uuid())
shopId String @unique
userId String
shopName String
accessToken String
refreshToken String
expiresAt BigInt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model BrandProfile {
id String @id @default(uuid())
name String @unique
referencePaths String // JSON array of file paths relative to /storage
createdAt DateTime @default(now())
// Relations
userId String?
user User? @relation(fields: [userId], references: [id])
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String? // Optional for Google Users
// OAuth Fields
googleId String? @unique
avatar String?
role String @default("USER") // USER, ADMIN
// SaaS / Beta Fields
credits Int @default(300) // Free credits (Generous tier)
plan String @default("FREE") // FREE, PRO
betaAccess Boolean @default(true) // Gated access
// New SaaS Fields (Phase 1)
apiKey String? // User's Personal Gemini API Key (Encrypted/Plain per policy)
etsyShopName String? // Manual Etsy Shop Name fallback
etsyShopLink String? // Manual Etsy Shop Link fallback
etsyShopLogo String? // Path to logo file
paymentMethod String? // Stripe Payment Method ID (Future)
subscriptionId String? // Stripe Subscription ID (Future)
skuSettings String? // JSON: { "Wall Art": {prefix: "WLR", next: 1} }
createdAt DateTime @default(now())
// Legal Compliance (Phase 6)
termsAcceptedAt DateTime? // User Agreement & IP Rights
kvkkAcceptedAt DateTime? // KVKK & Data Privacy
// Profit Analytics (Phase 7)
totalRevenue Float @default(0.0) // Total money paid by user (USD)
totalCost Float @default(0.0) // Total API cost incurred by user (USD, estimated)
projects Project[]
brandProfiles BrandProfile[]
etsyShops EtsyShop[]
usageLogs UsageLog[]
transactions Transaction[]
}
model UsageLog {
id String @id @default(uuid())
userId String
action String // GENERATE_MASTER, GENERATE_VARIANT, MOCKUP, PROMPT
cost Float // Estimated API cost in USD for this action
credits Int // Credits deducted
timestamp DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Transaction {
id String @id @default(uuid())
userId String
amount Float // USD Amount paid
credits Int // Credits added
type String // PURCHASE, ADMIN_GRANT, BONUS
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model SystemConfig {
key String @id @unique
value String
description String?
updatedAt DateTime @updatedAt
}

32
reset_admin_password.ts Normal file
View File

@@ -0,0 +1,32 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function resetAdmin() {
const email = 'admin@digicraft.app';
const password = 'adminpassword';
const role = 'ADMIN';
console.log(`Resetting Admin password for: ${email}`);
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
try {
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, role },
create: { email, passwordHash, role }
});
console.log(`✅ SUCCESS! Admin User ${email} is ready.`);
console.log(`🔑 Password: ${password}`);
} catch (e) {
console.error("❌ Error upserting admin:", e);
} finally {
await prisma.$disconnect();
}
}
resetAdmin();

32
reset_test_password.ts Normal file
View File

@@ -0,0 +1,32 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function upsertUser() {
const email = 'test@example.com';
const password = 'password123';
const role = 'ADMIN';
console.log(`Upserting user: ${email}`);
const salt = await bcrypt.genSalt(10);
const passwordHash = await bcrypt.hash(password, salt);
try {
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash, role },
create: { email, passwordHash, role }
});
console.log(`✅ SUCCESS! User ${email} is ready.`);
console.log(`🔑 Password: ${password}`);
} catch (e) {
console.error("❌ Error upserting user:", e);
} finally {
await prisma.$disconnect();
}
}
upsertUser();

36
scripts/count_projects.ts Normal file
View File

@@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log("📊 DATABASE AUDIT:");
// Count Users
const userCount = await prisma.user.count();
console.log(`- Total Users: ${userCount}`);
const users = await prisma.user.findMany({
include: { _count: { select: { projects: true } } }
});
users.forEach(u => {
console.log(` 👤 ${u.email} (${u.role}): ${u._count.projects} projects`);
});
// Count Projects
const projectCount = await prisma.project.count();
console.log(`\n- Total Projects: ${projectCount}`);
const projects = await prisma.project.findMany({
select: { id: true, niche: true, createdAt: true, userId: true },
orderBy: { createdAt: 'desc' },
take: 5
});
console.log("\nRecent Projects:");
projects.forEach(p => console.log(` 📂 ${p.niche} (${p.createdAt.toISOString()}) - User: ${p.userId}`));
await prisma.$disconnect();
}
main();

View File

@@ -0,0 +1,59 @@
import { PrismaClient } from '@prisma/client';
import dotenv from 'dotenv';
dotenv.config();
const prisma = new PrismaClient();
async function fix() {
console.log("🚑 STARTING EMERGENCY JSON REPAIR...");
const recoveredProjects = await prisma.project.findMany({
where: {
OR: [
{ niche: { contains: "Recovered Project" } },
{ seoData: { is: null } }
]
},
include: { seoData: true }
});
console.log(`🔍 Found ${recoveredProjects.length} projects to tokenize.`);
let successCount = 0;
for (const project of recoveredProjects) {
try {
// Force minimal valid JSON
// If niche contains "Recovered Project", use it, else generic.
const safeTitle = `Recovered Project ${project.niche.replace('Recovered Project ', '').substring(0, 5)}`;
await prisma.seoData.upsert({
where: { projectId: project.id },
create: {
projectId: project.id,
title: safeTitle,
description: "Project recovered from storage. Metadata pending reconstruction.",
keywords: JSON.stringify(["Recovered", "Storage", "Needs Review"]),
jsonLd: "{}", // Empty valid JSON
printingGuide: "Standard Guide",
suggestedPrice: "5.00"
},
update: {
// Update only if invalid or generic
// Actually, force update to fix the "keywords string" crash
keywords: JSON.stringify(["Recovered", "Storage", "Needs Review"]),
jsonLd: "{}"
}
});
process.stdout.write('.');
successCount++;
} catch (e: any) {
console.error(`Error ${project.id}:`, e.message);
}
}
console.log(`\n✅ REPAIR COMPLETE. Tokens Fixed: ${successCount}`);
}
fix();

View File

@@ -0,0 +1,200 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { GoogleGenAI, Type, Part } from "@google/genai";
import fs from 'fs';
import path from 'path';
import dotenv from 'dotenv';
import sharp from 'sharp'; // Correct top-level import
// Load environment variables manually if helpful, but usually preloaded
dotenv.config();
const prisma = new PrismaClient();
// Adjust API Key retrieval if needed
const API_KEY = process.env.GEMINI_API_KEY || process.env.API_KEY || "";
const ai = new GoogleGenAI({ apiKey: API_KEY });
const STORAGE_ROOT = path.join(process.cwd(), '..', 'storage');
async function reconstruct() {
console.log("🚑 STARTING AI METADATA RECONSTRUCTION (v2)...");
// We target "Recovered Project" in particular
// Note: Project model does not have strategy column, so we rely on Niche check
const recoveredProjects = await prisma.project.findMany({
where: {
OR: [
{ niche: { contains: "Recovered Project" } },
{ seoData: { is: null } }
]
},
include: {
assets: true,
seoData: true
}
});
console.log(`🔍 Found ${recoveredProjects.length} projects needing reconstruction.`);
let successCount = 0;
for (const project of recoveredProjects) {
try {
// Find Master Asset
const masterAsset = project.assets.find(a => a.type === 'master' || a.type === 'MASTER');
if (!masterAsset) {
// console.log(`⚠️ Skiping ${project.id}: No master asset.`);
continue;
}
const imagePath = path.join(STORAGE_ROOT, masterAsset.path.replace('projects/', 'projects/'));
// Correction: masterAsset.path is relative to storage root?
// masterAsset.path usually is "projects/UUID/master/file.png"
// STORAGE_ROOT is ".../storage"
// Join safely.
const fullPath = path.join(STORAGE_ROOT, ...masterAsset.path.split('/').slice(1));
// Wait, if path is "projects/..." and STORAGE_ROOT has "storage", we need to check real fs path.
// Let's try direct join first.
let realPath = path.join(process.cwd(), '../storage', masterAsset.path);
if (!fs.existsSync(realPath)) {
// Try alternative path (sometimes assets stored with leading slash?)
realPath = path.join(process.cwd(), '../storage', masterAsset.path.replace(/^projects\//, ''));
}
if (!fs.existsSync(realPath)) {
console.log(`❌ Image not found on disk: ${realPath}`);
continue;
}
// Read Image
const rawBuffer = fs.readFileSync(realPath);
// Resize if too large (Optimization)
const resizedBuffer = await sharp(rawBuffer)
.resize({ width: 1024, height: 1024, fit: 'inside' })
.toBuffer();
const base64Image = resizedBuffer.toString('base64');
// CALL GEMINI
console.log(`🤖 Analyzing ${project.id.substring(0, 8)}...`);
// Construct Prompt Parts
const parts: Part[] = [
{
text: `
You are a Professional Market Analyst and SEO Specialist.
Analyze this image (which is a digital product for Etsy) to reconstruct its lost metadata.
IMPORTANT:
- Keep "description" SHORT and CONCISE (max 50 words).
- Return exactly 13 "keywords".
- Do not hallucinate long text.
Return ONLY a JSON object:
{
"niche": "specific niche string",
"productType": "Wall Art",
"title": "SEO Title",
"description": "Short Marketing Description",
"keywords": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9", "tag10", "tag11", "tag12", "tag13"],
"creativity": "Balanced",
"jsonLd": { ...valid schema... }
}
` },
{ inlineData: { data: base64Image, mimeType: "image/png" } }
];
const result = await ai.models.generateContent({
model: "gemini-flash-latest", // 1.5 Flash (Confirmed available)
contents: { parts },
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
niche: { type: Type.STRING },
productType: { type: Type.STRING },
title: { type: Type.STRING },
description: { type: Type.STRING },
keywords: { type: Type.ARRAY, items: { type: Type.STRING } },
creativity: { type: Type.STRING },
jsonLd: {
type: Type.OBJECT,
properties: {
"@context": { type: Type.STRING },
"@type": { type: Type.STRING },
name: { type: Type.STRING },
description: { type: Type.STRING },
image: { type: Type.STRING },
brand: { type: Type.STRING }
}
}
}
}
}
});
let responseText = result.text;
if (!responseText) throw new Error("Empty AI Response");
// Clean markdown fences
responseText = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
let metadata;
try {
metadata = JSON.parse(responseText);
} catch (jsonErr) {
throw new Error("Failed to parse JSON: " + responseText.substring(0, 100));
}
// Defaults if missing
const finalTitle = metadata.title || `Recovered Project ${project.niche.substring(0, 10)}`;
const finalDescription = metadata.description || "Project metadata recovered from image analysis.";
const finalKeywords = Array.isArray(metadata.keywords) ? metadata.keywords : ["Recovered"];
// UDPATE DATABASE
await prisma.project.update({
where: { id: project.id },
data: {
niche: metadata.niche || "Recovered Art",
productType: metadata.productType || "Wall Art"
}
});
await prisma.seoData.upsert({
where: { projectId: project.id },
create: {
projectId: project.id,
title: finalTitle,
description: finalDescription,
keywords: JSON.stringify(finalKeywords),
jsonLd: JSON.stringify(metadata.jsonLd || {}),
printingGuide: "Standard",
suggestedPrice: "5.00"
},
update: {
title: finalTitle,
description: finalDescription,
keywords: JSON.stringify(finalKeywords),
jsonLd: JSON.stringify(metadata.jsonLd || {})
}
});
process.stdout.write('+');
successCount++;
} catch (err: any) {
console.error(`❌ Error ${project.id}:`, err.message);
// Log preview if it was a parse error
if (err.message.includes('JSON')) {
console.log("Response Preview:", err.stack); // Stack is too noisy, but okay for now.
}
}
}
console.log(`\n🎉 RECONSTRUCTION COMPLETE. Fixed ${successCount} projects.`);
await prisma.$disconnect();
}
reconstruct();

View File

@@ -0,0 +1,150 @@
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
const STORAGE_ROOT = '/Users/haruncan/Downloads/DEV/etsy-mastermind-engine-v13.1/storage';
async function recover() {
console.log("🚑 STARTING EMERGENCY RECOVERY V2...");
const adminUser = await prisma.user.findUnique({ where: { email: 'admin@digicraft.app' } });
if (!adminUser) {
console.error("❌ Admin user not found.");
return;
}
const projectsDir = path.join(STORAGE_ROOT, 'projects');
if (!fs.existsSync(projectsDir)) return;
const folders = fs.readdirSync(projectsDir).filter(f => fs.statSync(path.join(projectsDir, f)).isDirectory());
console.log(`📂 Scanning ${folders.length} folders...`);
let recoveredCount = 0;
for (const projectId of folders) {
// 1. Gather Metadata First
const projectPath = path.join(projectsDir, projectId);
const stats = fs.statSync(projectPath);
const createdAt = stats.birthtime;
// Niche Extraction
let niche = `Recovered Project (${projectId.substring(0, 8)})`;
const guidePath = path.join(projectPath, 'docs', 'Printing_Guide.txt');
if (fs.existsSync(guidePath)) {
// Can be enhanced to read content later
}
// Assets Scan
const masterDir = path.join(projectPath, 'master');
const masterFiles = fs.existsSync(masterDir)
? fs.readdirSync(masterDir).filter(f => f.endsWith('.png') || f.endsWith('.jpg'))
: [];
if (masterFiles.length === 0) {
// Skip empty projects to avoid clutter? Or recover anyway?
// Let's recover anyway but mark as draft? No, skip if no master.
// console.warn(`Skipping ${projectId} (No Master)`);
continue;
}
// 2. DB Operations
const exists = await prisma.project.findUnique({ where: { id: projectId } });
if (exists) {
// Repair Existing
await prisma.project.update({
where: { id: projectId },
data: {
userId: adminUser.id,
status: "COMPLETED",
niche: niche // Update niche incase it was generic
}
});
process.stdout.write('.');
} else {
// Create New
await prisma.project.create({
data: {
id: projectId,
userId: adminUser.id,
niche: niche,
productType: "Wall Art",
creativity: "Balanced",
aspectRatio: "3:4",
status: "COMPLETED",
createdAt: createdAt
}
});
process.stdout.write('+');
}
// ALWAYS ENSURE SEO DATA (UPSERT)
const seoDataPayload = {
title: niche,
description: "Recovered from storage.",
keywords: "recovered, art, vintage",
printingGuide: "Standard",
suggestedPrice: "5.00",
jsonLd: JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
"name": niche,
"description": "Recovered from storage.",
"offers": {
"@type": "Offer",
"price": "5.00",
"priceCurrency": "USD"
}
})
};
const seoExists = await prisma.seoData.findUnique({ where: { projectId: projectId } });
if (!seoExists) {
await prisma.seoData.create({
data: { ...seoDataPayload, projectId: projectId }
});
} else {
// Force update to ensure JSON-LD is there
await prisma.seoData.update({
where: { projectId: projectId },
data: seoDataPayload
});
process.stdout.write('s'); // seo update
}
// 4. Ensure Assets
for (const file of masterFiles) {
const assetPath = `projects/${projectId}/master/${file}`;
const assetExists = await prisma.asset.findFirst({ where: { path: assetPath } });
if (!assetExists) {
await prisma.asset.create({
data: {
projectId: projectId,
type: "master", // LOWERCASE to match API expectation
path: assetPath,
createdAt: fs.statSync(path.join(masterDir, file)).birthtime,
quality: "MASTER"
}
});
} else {
// Fix bad casing if exists
if (assetExists.type === 'MASTER') {
await prisma.asset.update({
where: { id: assetExists.id },
data: { type: 'master' }
});
process.stdout.write('f'); // fix
}
}
}
recoveredCount++;
}
console.log(`\n🎉 Processed ${recoveredCount} projects.`);
await prisma.$disconnect();
}
recover();

View File

@@ -0,0 +1,34 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function transfer() {
// Get target email from CLI args
const targetEmail = process.argv[2];
if (!targetEmail) {
console.error("❌ Usage: npx tsx scripts/transfer_projects.ts <target_email>");
process.exit(1);
}
console.log(`🚚 STARTING MIGRATION to: ${targetEmail}`);
// 1. Find Target User
const targetUser = await prisma.user.findUnique({ where: { email: targetEmail } });
if (!targetUser) {
console.error(`❌ User ${targetEmail} not found. Please register first!`);
process.exit(1);
}
// 2. Update ALL projects to point to this user
// (Except maybe "God Mode Test" projects? No, give them everything)
const result = await prisma.project.updateMany({
data: { userId: targetUser.id }
});
console.log(`✅ SUCCESS: Transferred ${result.count} projects to ${targetEmail}.`);
console.log(`🎉 They should now appear in the dashboard.`);
await prisma.$disconnect();
}
transfer();

535
services/archiveService.ts Normal file
View 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
View 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
View 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);
}
}
};

View 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
View 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
View 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');
}
};

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"es2020"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./"
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules"
]
}

179
utils/aiReconstruction.ts Normal file
View File

@@ -0,0 +1,179 @@
import { PrismaClient } from '@prisma/client';
import { GoogleGenAI, Type, Part } from "@google/genai";
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
interface ReconstructionOptions {
projectId: string;
prisma: PrismaClient;
ai: GoogleGenAI;
storageRoot: string;
}
export async function reconstructProjectMetadata({ projectId, prisma, ai, storageRoot }: ReconstructionOptions) {
console.log(`[AI-Reconstruction] Starting analysis for project: ${projectId}`);
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { assets: true, seoData: true }
});
if (!project) throw new Error("Project not found");
// FIND MASTER ASSET
const masterAsset = project.assets.find(a => a.type === 'master' || a.type === 'MASTER' || a.type === 'upscaled');
if (!masterAsset) {
console.warn(`[AI-Reconstruction] No master/upscaled asset found for ${projectId}.`);
throw new Error("No master image found to analyze.");
}
// RESOLVE PATH
let realPath = path.join(storageRoot, masterAsset.path);
if (!fs.existsSync(realPath)) {
// Try fallback path (sometimes path already includes 'projects/' prefix, sometimes not relative to storage root in same way)
// If masterAsset.path starts with 'projects/', and storageRoot ends with 'storage', simple join usually works.
// Try stripping 'projects/' prefix if needed.
const altPath = path.join(storageRoot, masterAsset.path.replace(/^projects\//, ''));
if (fs.existsSync(altPath)) {
realPath = altPath;
} else {
// Try assuming it's relative to CWD if local debug
const localPath = path.resolve(process.cwd(), '../storage', masterAsset.path);
if (fs.existsSync(localPath)) realPath = localPath;
}
}
if (!fs.existsSync(realPath)) {
throw new Error(`Master asset file not found on disk at: ${realPath}`);
}
// PROCESS IMAGE
const rawBuffer = fs.readFileSync(realPath);
// Resize for AI Latency Optimization
const resizedBuffer = await sharp(rawBuffer)
.resize({ width: 1024, height: 1024, fit: 'inside' })
.toBuffer();
const base64Image = resizedBuffer.toString('base64');
console.log(`[AI-Reconstruction] Image processed (${rawBuffer.length} -> ${resizedBuffer.length} bytes). calling Gemini...`);
// CALL GEMINI
const parts: Part[] = [
{
text: `
You are a Professional Market Analyst and SEO Specialist.
Analyze this image (which is a digital product for Etsy) to reconstruct its lost metadata.
IMPORTANT:
- Keep "description" SHORT and CONCISE (max 50 words).
- Return exactly 13 "keywords".
- Do not hallucinate long text.
- "jsonLd" should be a valid schema.org Product or CreativeWork object.
Return ONLY a JSON object:
{
"niche": "specific niche string",
"productType": "Wall Art",
"title": "SEO Title",
"description": "Short Marketing Description",
"keywords": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9", "tag10", "tag11", "tag12", "tag13"],
"creativity": "Balanced",
"jsonLd": { "@context": "https://schema.org", "@type": "Product", "name": "...", "description": "...", "image": "...", "brand": "..." }
}
` },
{ inlineData: { data: base64Image, mimeType: "image/png" } }
];
const result = await ai.models.generateContent({
model: "gemini-flash-latest", // 1.5 Flash (Confirmed Stable)
contents: { parts },
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
niche: { type: Type.STRING },
productType: { type: Type.STRING },
title: { type: Type.STRING },
description: { type: Type.STRING },
keywords: { type: Type.ARRAY, items: { type: Type.STRING } },
creativity: { type: Type.STRING },
jsonLd: {
type: Type.OBJECT,
properties: {
"@context": { type: Type.STRING },
"@type": { type: Type.STRING },
name: { type: Type.STRING },
description: { type: Type.STRING },
image: { type: Type.STRING },
brand: { type: Type.STRING }
}
}
}
}
}
});
let responseText = result.text;
if (!responseText) throw new Error("Empty AI Response");
// Clean JSON
responseText = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
let metadata;
try {
metadata = JSON.parse(responseText);
} catch (e) {
console.error("JSON Parse Error:", responseText);
throw new Error("Failed to parse AI response JSON");
}
const finalTitle = metadata.title || `Recovered Project ${project.niche?.substring(0, 10) || 'Art'}`;
const finalDescription = metadata.description || "Project metadata recovered from image analysis.";
const finalKeywords = Array.isArray(metadata.keywords) ? metadata.keywords : ["Recovered"];
// UPDATE DB
// Update Project
await prisma.project.update({
where: { id: projectId },
data: {
niche: metadata.niche || "Recovered Art",
productType: metadata.productType || "Wall Art"
}
});
// Update SeoData
const updatedSeo = await prisma.seoData.upsert({
where: { projectId: projectId },
create: {
projectId: projectId,
title: finalTitle,
description: finalDescription,
keywords: JSON.stringify(finalKeywords),
jsonLd: JSON.stringify(metadata.jsonLd || {}),
printingGuide: "Standard",
suggestedPrice: "5.00"
},
update: {
title: finalTitle,
description: finalDescription,
keywords: JSON.stringify(finalKeywords),
jsonLd: JSON.stringify(metadata.jsonLd || {})
}
});
// Strategy Object Return for frontend
return {
seoTitle: finalTitle,
description: finalDescription,
keywords: finalKeywords,
printingGuide: "Standard",
suggestedPrice: "5.00",
jsonLd: metadata.jsonLd // return object, endpoint might stringify or not
};
}

106
verify_auth.ts Normal file
View File

@@ -0,0 +1,106 @@
import dotenv from "dotenv";
dotenv.config();
const API_URL = 'http://localhost:3001/api';
async function testAuth() {
console.log("🚀 Starting Authentication Verification...");
// Helper to sleep
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
// GENERATE RANDOM USERS
const userA = { email: `userA_${Date.now()}@test.com`, password: 'password123' };
const userB = { email: `userB_${Date.now()}@test.com`, password: 'password123' };
let tokenA = '';
let tokenB = '';
try {
// 1. REGISTER USER A
console.log(`\n👤 Registering User A: ${userA.email}`);
const regA = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...userA, apiKey: process.env.GEMINI_API_KEY, termsAccepted: true })
});
const dataA = await regA.json() as any;
if (!regA.ok) throw new Error(`User A Register Failed: ${JSON.stringify(dataA)}`);
tokenA = dataA.token;
console.log("✅ User A Registered & Token Received");
// 2. REGISTER USER B
console.log(`\n👤 Registering User B: ${userB.email}`);
const regB = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...userB, apiKey: process.env.GEMINI_API_KEY, termsAccepted: true })
});
const dataB = await regB.json() as any;
if (!regB.ok) throw new Error(`User B Register Failed: ${JSON.stringify(dataB)}`);
tokenB = dataB.token;
console.log("✅ User B Registered & Token Received");
// 3. CREATE PROJECT AS USER A
console.log("\n🎨 User A creating project 'My Private Art'");
const projA = await fetch(`${API_URL}/projects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenA}`
},
body: JSON.stringify({
niche: "Abstract Blue Shapes",
productType: "Wall Art",
creativity: "Balanced",
aspectRatio: "3:4",
referenceImages: []
})
});
const projDataA = await projA.json() as any;
if (!projA.ok) throw new Error(`Project Creation Failed: ${JSON.stringify(projDataA)}`);
console.log("✅ User A Project Created");
// 4. VERIFY USER B CANNOT SEE USER A's PROJECT
console.log("\n🕵 User B attempting to view projects...");
const getB = await fetch(`${API_URL}/projects`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${tokenB}` }
});
const getDataB = await getB.json() as any;
const projectsB = getDataB.projects || [];
console.log(` User B sees ${projectsB.length} projects.`);
if (projectsB.length === 0) {
console.log("✅ SUCCESS: User B sees 0 projects.");
} else {
console.error("❌ FAILURE: User B sees projects they shouldn't!");
console.error(projectsB);
}
// 5. VERIFY USER A CAN SEE THEIR PROJECT
console.log("\n🕵 User A attempting to view projects...");
const getA = await fetch(`${API_URL}/projects`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${tokenA}` }
});
const getDataA = await getA.json() as any;
const projectsA = getDataA.projects || [];
console.log(` User A sees ${projectsA.length} projects.`);
if (projectsA.length >= 1) {
console.log("✅ SUCCESS: User A sees their project.");
} else {
console.error("❌ FAILURE: User A cannot see their project!");
}
console.log("\n🎉 AUTHENTICATION & RBAC VERIFICATION COMPLETE!");
} catch (error) {
console.error("\n❌ VERIFICATION FAILED:", error);
}
}
testAuth();