import AdminJS, { ComponentLoader } from 'adminjs' import AdminJSFastify from '@adminjs/fastify' import { Database, Resource, getModelByName } from '@adminjs/prisma' import uploadFeature from '@adminjs/upload' import bcrypt from 'bcrypt' import Stripe from 'stripe' import path from 'path' import { exec } from 'child_process' import { prisma } from './src/lib/db.mjs' AdminJS.registerAdapter({ Database, Resource }) const componentLoader = new ComponentLoader() const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY) : null const DOMAIN = process.env.DOMAIN ?? 'https://rebours.studio' const UPLOADS_DIR = path.resolve('uploads') // ── Auto-build (prod only) ────────────────────────────────────────────────── let buildTimeout = null let buildInProgress = false function triggerBuild() { if (process.env.NODE_ENV !== 'production') { console.log('[admin] Dev mode — skipping build') return } if (buildTimeout) clearTimeout(buildTimeout) buildTimeout = setTimeout(() => { if (buildInProgress) return buildInProgress = true console.log('[admin] Building site...') exec('pnpm build', { cwd: process.cwd(), timeout: 120_000 }, (err, stdout, stderr) => { buildInProgress = false if (err) console.error('[admin] Build FAILED:', stderr) else console.log('[admin] Build OK') }) }, 5_000) } const afterBuildHook = async (response) => { triggerBuild(); return response } // ── Stripe price sync after product edit ───────────────────────────────────── async function syncPriceToStripe(response) { if (!stripe) return response const product = await prisma.product.findUnique({ where: { id: response.record.params.id }, }) if (!product) return response const priceCents = product.price const currency = (product.currency || 'EUR').toLowerCase() const slug = product.slug try { // 1. Find or create Stripe product by metadata.slug let stripeProduct = null const existing = await stripe.products.search({ query: `metadata["slug"]:"${slug}"` }) if (existing.data.length > 0) { stripeProduct = existing.data[0] await stripe.products.update(stripeProduct.id, { name: product.productDisplayName, description: product.description, images: product.ogImage ? [product.ogImage] : undefined, active: true, }) } else if (priceCents) { stripeProduct = await stripe.products.create({ name: product.productDisplayName, description: product.description, images: product.ogImage ? [product.ogImage] : [], metadata: { slug, stripeKey: product.stripeKey || slug, dbId: product.id }, }) console.log(`[stripe] Product created: ${stripeProduct.id}`) } if (!stripeProduct) return response // 2. If price is null, archive old price and clear DB if (!priceCents) { if (product.stripePriceId) { await stripe.prices.update(product.stripePriceId, { active: false }) await prisma.product.update({ where: { id: product.id }, data: { stripePriceId: null } }) console.log(`[stripe] Price archived (product has no price now)`) } return response } // 3. Check if current Stripe price matches if (product.stripePriceId) { try { const currentPrice = await stripe.prices.retrieve(product.stripePriceId) if (currentPrice.unit_amount === priceCents && currentPrice.currency === currency && currentPrice.active) { return response } await stripe.prices.update(product.stripePriceId, { active: false }) console.log(`[stripe] Old price archived: ${product.stripePriceId}`) } catch { // Old price doesn't exist, continue } } // 4. Create new price const newPrice = await stripe.prices.create({ product: stripeProduct.id, unit_amount: priceCents, currency, metadata: { slug, dbId: product.id }, }) console.log(`[stripe] New price created: ${newPrice.id} (${priceCents / 100} ${currency.toUpperCase()})`) // 5. Update DB await prisma.product.update({ where: { id: product.id }, data: { stripePriceId: newPrice.id }, }) } catch (err) { console.error(`[stripe] Sync error for ${slug}:`, err.message) } return response } // ── Auto-compute fields after save ─────────────────────────────────────────── async function autoComputeFields(response) { const id = response.record.params.id const product = await prisma.product.findUnique({ where: { id } }) if (!product) return response const updates = {} // Auto-generate slug from name if empty if (!product.slug || product.slug === '') { updates.slug = product.name .toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/_/g, '-') .replace(/[^a-z0-9-]/g, '') } // Auto-set stripeKey from slug if (!product.stripeKey) { const slug = updates.slug || product.slug updates.stripeKey = slug.replace(/-/g, '_') } // Auto-set imagePath and ogImage from uploaded image if (product.imageKey) { const newImagePath = `/uploads/${product.imageKey}` if (product.imagePath !== newImagePath) { updates.imagePath = newImagePath updates.ogImage = `${DOMAIN}/uploads/${product.imageKey}` } } // Auto-generate seoTitle if empty if (!product.seoTitle || product.seoTitle === '') { updates.seoTitle = `REBOURS — ${product.productDisplayName || product.name} | Collection 001` } // Auto-generate seoDescription if empty if (!product.seoDescription || product.seoDescription === '') { updates.seoDescription = product.description ? product.description.substring(0, 155) + '...' : `${product.productDisplayName} — Pièce unique fabriquée à Paris. REBOURS Studio.` } if (Object.keys(updates).length > 0) { const updated = await prisma.product.update({ where: { id }, data: updates }) // Reflect updates in AdminJS response for (const [key, val] of Object.entries(updates)) { response.record.params[key] = val } console.log(`[admin] Auto-computed fields for ${product.name}:`, Object.keys(updates).join(', ')) } return response } // ── AdminJS setup ─────────────────────────────────────────────────────────── export async function setupAdmin(app) { const admin = new AdminJS({ rootPath: '/admin', componentLoader, resources: [ { resource: { model: getModelByName('Product'), client: prisma }, options: { navigation: { name: 'Contenu', icon: 'Package' }, listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'], editProperties: [ 'imageUpload', 'name', 'productDisplayName', 'slug', 'sortOrder', 'index', 'type', 'materials', 'year', 'status', 'description', 'specs', 'notes', 'imageAlt', 'seoTitle', 'seoDescription', 'price', 'currency', 'availability', 'isPublished', ], showProperties: [ 'name', 'productDisplayName', 'slug', 'imagePath', 'imageAlt', 'ogImage', 'index', 'type', 'materials', 'year', 'status', 'description', 'specs', 'notes', 'seoTitle', 'seoDescription', 'price', 'currency', 'availability', 'stripePriceId', 'stripeKey', 'isPublished', 'createdAt', 'updatedAt', ], properties: { description: { type: 'textarea' }, specs: { type: 'textarea' }, notes: { type: 'textarea' }, seoDescription: { type: 'textarea' }, price: { description: 'Prix en centimes (ex: 180000 = 1800€). Laisser vide = non disponible.', }, slug: { description: 'Auto-généré depuis le nom si vide.', }, seoTitle: { description: 'Auto-généré si vide.', }, seoDescription: { description: 'Auto-généré depuis la description si vide.', }, // Hide technical fields from edit imagePath: { isVisible: { edit: false, new: false, list: false, show: true } }, ogImage: { isVisible: { edit: false, new: false, list: false, show: true } }, stripePriceId: { isVisible: { edit: false, new: false, list: false, show: true } }, stripeKey: { isVisible: { edit: false, new: false, list: false, show: true } }, imageKey: { isVisible: false }, imageMime: { isVisible: false }, }, actions: { new: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] }, edit: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] }, delete: { after: [afterBuildHook] }, }, }, features: [ uploadFeature({ componentLoader, provider: { local: { bucket: UPLOADS_DIR }, }, properties: { key: 'imageKey', mimeType: 'imageMime', file: 'imageUpload', }, validation: { mimeTypes: ['image/jpeg', 'image/png', 'image/webp'], maxSize: 5 * 1024 * 1024, // 5 MB }, }), ], }, { resource: { model: getModelByName('Order'), client: prisma }, options: { navigation: { name: 'Commerce', icon: 'CreditCard' }, listProperties: ['stripeSessionId', 'status', 'amount', 'customerEmail', 'productSlug', 'createdAt'], actions: { new: { isAccessible: false }, edit: { isAccessible: false }, delete: { isAccessible: false }, }, }, }, ], branding: { companyName: 'REBOURS Studio', logo: false, withMadeWithLove: false, }, }) await AdminJSFastify.buildAuthenticatedRouter( admin, { authenticate: async (email, password) => { const user = await prisma.adminUser.findUnique({ where: { email } }) if (!user) return null const valid = await bcrypt.compare(password, user.passwordHash) return valid ? { email: user.email, id: user.id } : null }, cookiePassword: process.env.COOKIE_SECRET ?? 'super-secret-cookie-password-at-least-32-chars', cookieName: 'adminjs', }, app, { saveUninitialized: false, cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', }, } ) return admin }