rebours/admin.mjs
ordinarthur 4bb9ed86d4 feat: auto-sync product prices to Stripe from admin + UI fixes
- Admin edit hook syncs prices to Stripe (create/archive)
- "Prochainement disponible" disabled button for products without price
- Seed no longer hardcodes stripePriceId
- Fix nginx port mismatch (3001 → 3000)
- Coord tag background color + sound auto-start fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 23:30:39 +01:00

200 lines
6.9 KiB
JavaScript

import AdminJS from 'adminjs'
import AdminJSFastify from '@adminjs/fastify'
import { Database, Resource, getModelByName } from '@adminjs/prisma'
import bcrypt from 'bcrypt'
import Stripe from 'stripe'
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
// ── 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]
// Update product info
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) {
// Price unchanged
return response
}
// Archive old price
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
}
// ── 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: [
'slug', 'sortOrder', 'index', 'name', 'type', 'materials',
'year', 'status', 'description', 'specs', 'notes',
'imagePath', 'imageAlt',
'seoTitle', 'seoDescription', 'ogImage',
'productDisplayName', 'price', 'currency', 'availability',
'stripePriceId', 'stripeKey', 'isPublished',
],
properties: {
description: { type: 'textarea' },
specs: { type: 'textarea' },
notes: { type: 'textarea' },
seoDescription: { type: 'textarea' },
},
actions: {
new: { after: [syncPriceToStripe, afterBuildHook] },
edit: { after: [syncPriceToStripe, afterBuildHook] },
delete: { after: [afterBuildHook] },
},
},
},
{
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
}