This commit is contained in:
37
.gitea/workflows/deploy.yml
Normal file
37
.gitea/workflows/deploy.yml
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal 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
34
clean_orphaned_assets.ts
Normal 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
43
create_admin.ts
Normal 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();
|
||||
5166
package-lock.json
generated
Normal file
5166
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
package.json
Normal file
49
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
prisma/migrations/20260102073121_init/migration.sql
Normal file
49
prisma/migrations/20260102073121_init/migration.sql
Normal 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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
114
prisma/migrations/20260107221253_add_json_ld/migration.sql
Normal file
114
prisma/migrations/20260107221253_add_json_ld/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
154
prisma/schema.prisma
Normal 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
32
reset_admin_password.ts
Normal 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
32
reset_test_password.ts
Normal 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
36
scripts/count_projects.ts
Normal 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();
|
||||
59
scripts/legacy/emergency_json_fix.ts
Normal file
59
scripts/legacy/emergency_json_fix.ts
Normal 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();
|
||||
200
scripts/legacy/reconstruct_metadata.ts
Normal file
200
scripts/legacy/reconstruct_metadata.ts
Normal 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();
|
||||
150
scripts/legacy/recover_orphaned_projects.ts
Normal file
150
scripts/legacy/recover_orphaned_projects.ts
Normal 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();
|
||||
34
scripts/transfer_projects.ts
Normal file
34
scripts/transfer_projects.ts
Normal 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
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');
|
||||
}
|
||||
};
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal 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
179
utils/aiReconstruction.ts
Normal 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
106
verify_auth.ts
Normal 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();
|
||||
Reference in New Issue
Block a user