- Add @adminjs/upload for image management (JPEG/PNG/WebP, max 5MB) - Auto-compute imagePath, ogImage, slug, seoTitle, seoDescription - Stripe price auto-sync on product create/edit - Serve uploads via Fastify + nginx /uploads/ location - Add imageKey/imageMime fields to schema - Hide technical fields from admin edit form - Add uploads/ and SQLite DB to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
import AdminJS 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 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',
|
|
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({
|
|
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
|
|
}
|