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:
parent
4bb9ed86d4
commit
f4ac81dac3
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
124
admin.mjs
@ -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 },
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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
1782
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "imageKey" TEXT;
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "imageMime" TEXT;
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user