feat: full product management from admin with image upload

- 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>
This commit is contained in:
ordinarthur 2026-03-20 23:38:11 +01:00
parent 4bb9ed86d4
commit f4ac81dac3
9 changed files with 1928 additions and 10 deletions

7
.gitignore vendored
View File

@ -43,3 +43,10 @@ ssl/
# Nginx logs volume # Nginx logs volume
nginx-logs/ nginx-logs/
# Uploaded images (managed via admin)
uploads/
# SQLite database files
prisma/*.db
prisma/*.db-journal

124
admin.mjs
View File

@ -1,8 +1,10 @@
import AdminJS from 'adminjs' import AdminJS from 'adminjs'
import AdminJSFastify from '@adminjs/fastify' import AdminJSFastify from '@adminjs/fastify'
import { Database, Resource, getModelByName } from '@adminjs/prisma' import { Database, Resource, getModelByName } from '@adminjs/prisma'
import uploadFeature from '@adminjs/upload'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import Stripe from 'stripe' import Stripe from 'stripe'
import path from 'path'
import { exec } from 'child_process' import { exec } from 'child_process'
import { prisma } from './src/lib/db.mjs' import { prisma } from './src/lib/db.mjs'
@ -12,6 +14,9 @@ const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY) ? new Stripe(process.env.STRIPE_SECRET_KEY)
: null : null
const DOMAIN = process.env.DOMAIN ?? 'https://rebours.studio'
const UPLOADS_DIR = path.resolve('uploads')
// ── Auto-build (prod only) ────────────────────────────────────────────────── // ── Auto-build (prod only) ──────────────────────────────────────────────────
let buildTimeout = null let buildTimeout = null
let buildInProgress = false let buildInProgress = false
@ -55,7 +60,6 @@ async function syncPriceToStripe(response) {
const existing = await stripe.products.search({ query: `metadata["slug"]:"${slug}"` }) const existing = await stripe.products.search({ query: `metadata["slug"]:"${slug}"` })
if (existing.data.length > 0) { if (existing.data.length > 0) {
stripeProduct = existing.data[0] stripeProduct = existing.data[0]
// Update product info
await stripe.products.update(stripeProduct.id, { await stripe.products.update(stripeProduct.id, {
name: product.productDisplayName, name: product.productDisplayName,
description: product.description, description: product.description,
@ -89,10 +93,8 @@ async function syncPriceToStripe(response) {
try { try {
const currentPrice = await stripe.prices.retrieve(product.stripePriceId) const currentPrice = await stripe.prices.retrieve(product.stripePriceId)
if (currentPrice.unit_amount === priceCents && currentPrice.currency === currency && currentPrice.active) { if (currentPrice.unit_amount === priceCents && currentPrice.currency === currency && currentPrice.active) {
// Price unchanged
return response return response
} }
// Archive old price
await stripe.prices.update(product.stripePriceId, { active: false }) await stripe.prices.update(product.stripePriceId, { active: false })
console.log(`[stripe] Old price archived: ${product.stripePriceId}`) console.log(`[stripe] Old price archived: ${product.stripePriceId}`)
} catch { } catch {
@ -114,7 +116,6 @@ async function syncPriceToStripe(response) {
where: { id: product.id }, where: { id: product.id },
data: { stripePriceId: newPrice.id }, data: { stripePriceId: newPrice.id },
}) })
} catch (err) { } catch (err) {
console.error(`[stripe] Sync error for ${slug}:`, err.message) console.error(`[stripe] Sync error for ${slug}:`, err.message)
} }
@ -122,6 +123,62 @@ async function syncPriceToStripe(response) {
return response 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 ─────────────────────────────────────────────────────────── // ── AdminJS setup ───────────────────────────────────────────────────────────
export async function setupAdmin(app) { export async function setupAdmin(app) {
const admin = new AdminJS({ const admin = new AdminJS({
@ -133,25 +190,72 @@ export async function setupAdmin(app) {
navigation: { name: 'Contenu', icon: 'Package' }, navigation: { name: 'Contenu', icon: 'Package' },
listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'], listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'],
editProperties: [ editProperties: [
'slug', 'sortOrder', 'index', 'name', 'type', 'materials', 'imageUpload',
'name', 'productDisplayName', 'slug',
'sortOrder', 'index', 'type', 'materials',
'year', 'status', 'description', 'specs', 'notes', 'year', 'status', 'description', 'specs', 'notes',
'imagePath', 'imageAlt', 'imageAlt',
'seoTitle', 'seoDescription', 'ogImage', 'seoTitle', 'seoDescription',
'productDisplayName', 'price', 'currency', 'availability', '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', 'stripePriceId', 'stripeKey', 'isPublished',
'createdAt', 'updatedAt',
], ],
properties: { properties: {
description: { type: 'textarea' }, description: { type: 'textarea' },
specs: { type: 'textarea' }, specs: { type: 'textarea' },
notes: { type: 'textarea' }, notes: { type: 'textarea' },
seoDescription: { 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: { actions: {
new: { after: [syncPriceToStripe, afterBuildHook] }, new: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] },
edit: { after: [syncPriceToStripe, afterBuildHook] }, edit: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] },
delete: { after: [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 }, resource: { model: getModelByName('Order'), client: prisma },

View File

@ -10,6 +10,10 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://127.0.0.1:8888', '/api': 'http://127.0.0.1:8888',
'/admin': 'http://127.0.0.1:8888', '/admin': 'http://127.0.0.1:8888',
'/uploads': {
target: 'http://127.0.0.1:8888',
rewrite: (p) => p,
},
}, },
}, },
}, },

View File

@ -36,6 +36,12 @@ server {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
} }
# ── Uploads (images produits depuis admin) ────────────────────────────────
location /uploads/ {
alias /var/www/html/rebours/uploads/;
add_header Cache-Control "public, max-age=604800";
}
# ── Cache : Astro hashed immutable ───────────────────────────────────── # ── Cache : Astro hashed immutable ─────────────────────────────────────
location /_astro/ { location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";

View File

@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"@adminjs/fastify": "^4.2.0", "@adminjs/fastify": "^4.2.0",
"@adminjs/prisma": "^5.0.4", "@adminjs/prisma": "^5.0.4",
"@adminjs/upload": "^4.0.2",
"@fastify/cors": "^10.0.2", "@fastify/cors": "^10.0.2",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",

1782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "imageKey" TEXT;
ALTER TABLE "Product" ADD COLUMN "imageMime" TEXT;

View File

@ -24,6 +24,8 @@ model Product {
notes String notes String
imagePath String imagePath String
imageAlt String @default("") imageAlt String @default("")
imageKey String? // upload filename
imageMime String? // upload mime type
// SEO // SEO
seoTitle String seoTitle String

View File

@ -1,7 +1,9 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import fastifyStatic from '@fastify/static'
import Stripe from 'stripe' import Stripe from 'stripe'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import path from 'path'
import { setupAdmin } from './admin.mjs' import { setupAdmin } from './admin.mjs'
import { prisma } from './src/lib/db.mjs' import { prisma } from './src/lib/db.mjs'
@ -13,6 +15,13 @@ const DOMAIN = process.env.DOMAIN ?? 'http://localhost:4321'
const app = Fastify({ logger: true, trustProxy: true }) const app = Fastify({ logger: true, trustProxy: true })
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] }) await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
// Serve uploaded images
await app.register(fastifyStatic, {
root: path.resolve('uploads'),
prefix: '/uploads/',
decorateReply: false,
})
// ── Webhook Stripe (AVANT AdminJS pour éviter les conflits de body parsing) ─ // ── Webhook Stripe (AVANT AdminJS pour éviter les conflits de body parsing) ─
app.post('/api/webhook', { app.post('/api/webhook', {
config: { rawBody: true }, config: { rawBody: true },