Harden production deployment workflow
This commit is contained in:
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Next.js
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_SITE_URL=https://www.eversolo.com
|
||||
|
||||
# Payload
|
||||
PAYLOAD_SECRET=replace-me-with-a-long-random-string
|
||||
DATABASE_URI=postgres://postgres:postgres@localhost:5432/eversoloweb
|
||||
PAYLOAD_DB_PUSH=false
|
||||
PAYLOAD_RUN_MIGRATIONS_ON_START=false
|
||||
|
||||
@@ -30,6 +30,8 @@ Project documentation for the Eversolo brand website redesign.
|
||||
- `ROADMAP.md` — phases, deliverables, roles
|
||||
- `DISCOVERY-QUESTIONS.md` — remaining open items
|
||||
- `docs/production-readiness.md` — launch hardening rules for CMS content, cache, admin UX, and QA
|
||||
- `docs/deployment-runbook.md` — production deployment, data export/import, migration, and rollback procedure
|
||||
- `docs/production-release-checklist.md` — preflight checklist for each release
|
||||
|
||||
## Status
|
||||
Phase 2/3 — Build and seed integration are now being hardened toward production readiness.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Deployment Runbook
|
||||
|
||||
This runbook is the source of truth for taking the Eversolo Payload/Next.js site from a local database and media folder to a production deployment.
|
||||
|
||||
## Release Inputs
|
||||
|
||||
- Git commit to deploy.
|
||||
- PostgreSQL database dump exported from the approved local database.
|
||||
- `media/` and `files/` directories exported from the same local workspace as the database dump.
|
||||
- Production environment variables from `.env.example`, filled with production values.
|
||||
|
||||
## Required Environment
|
||||
|
||||
Use Node.js 22 LTS or newer. The project declares `>=20.9.0`, but production should use one pinned LTS line.
|
||||
|
||||
Required variables:
|
||||
|
||||
- `NEXT_PUBLIC_SITE_URL`: public canonical origin, for example `https://www.eversolo.com`.
|
||||
- `PAYLOAD_SECRET`: long random secret, never use the example value.
|
||||
- `DATABASE_URI`: PostgreSQL connection string.
|
||||
- `PAYLOAD_DB_PUSH=false`: production must use migrations, not schema push.
|
||||
- `PAYLOAD_RUN_MIGRATIONS_ON_START=false`: production startup should not run migrations interactively.
|
||||
|
||||
Optional variables:
|
||||
|
||||
- `PORT`: runtime port for `next start`.
|
||||
- `SMOKE_BASE_URL`: base URL used by `npm run smoke`.
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. Restore the approved PostgreSQL dump into the production database.
|
||||
2. Copy `media/` and `files/` to the production persistent upload volume.
|
||||
3. Install dependencies with `npm ci`.
|
||||
4. Run `npm run generate:types` only during build validation, not as a production data mutation step.
|
||||
5. Run `npm run typecheck`.
|
||||
6. Run `npm run build`.
|
||||
7. Run `npm run payload:migrate`.
|
||||
8. Run `npm run payload:migrate:status` and confirm every migration is `Yes`.
|
||||
9. Start the app with `npm run start`.
|
||||
10. Run `npm run smoke -- "$NEXT_PUBLIC_SITE_URL"`.
|
||||
|
||||
## Data Rules
|
||||
|
||||
- Do not run `PAYLOAD_DB_PUSH=true` against production.
|
||||
- Do not rely on application startup to run migrations. Use the explicit migration step.
|
||||
- Before exporting a local database for production, confirm `payload_migrations` has no `batch = -1` dev marker. That marker means schema push history is still recorded and production migration commands can prompt interactively.
|
||||
- Do not edit migrations after they have shipped to production. Add a new forward migration instead.
|
||||
- Keep schema migrations and data backfills separate. Backfill scripts in `src/scripts/` are manual operational tools, not automatic boot steps.
|
||||
- The deployed database and upload directories must come from the same local export, otherwise media relationships can point at missing files.
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback means switching code and data together:
|
||||
|
||||
1. Stop traffic or move traffic back to the previous deployment.
|
||||
2. Restore the previous database dump.
|
||||
3. Restore the matching previous `media/` and `files/` directories.
|
||||
4. Deploy the previous Git commit.
|
||||
5. Re-run smoke checks before reopening traffic.
|
||||
|
||||
Do not run migration down scripts on production as the default rollback path.
|
||||
@@ -23,3 +23,13 @@ This project is being tightened toward launch as a CMS-first Eversolo brand site
|
||||
- Prefer compact admin groups, list-style arrays, and explicit per-locale documents for user-facing content.
|
||||
- Re-run `npm run generate:types`, `npm run build`, and the smoke check after schema or cache changes.
|
||||
- After restarting a local preview, check both `/en` and `/zh` for homepage, product detail, downloads, news, dealers, support, and admin access.
|
||||
|
||||
## Deployment Discipline
|
||||
|
||||
- Production uses migrations only. `PAYLOAD_DB_PUSH` must be `false`.
|
||||
- Production startup does not auto-run migrations by default. Run `npm run payload:migrate` as an explicit release step.
|
||||
- The local release database must not contain Payload's `dev` migration marker (`batch = -1`) before it is exported.
|
||||
- Local database exports must be paired with the same `media/` and `files/` directories.
|
||||
- Backfill scripts are manual release tools. They should be run before export, verified, and then treated as data already present in the production dump.
|
||||
- Production startup should fail fast if required secrets are missing instead of booting with development defaults.
|
||||
- `/api/health` should return `200` before traffic is switched to the deployment.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Production Release Checklist
|
||||
|
||||
Use this checklist before every production deployment.
|
||||
|
||||
## Code
|
||||
|
||||
- [ ] Working tree is clean.
|
||||
- [ ] Latest commit is the intended release commit.
|
||||
- [ ] `npm ci` succeeds from a clean install.
|
||||
- [ ] `npm run typecheck` passes.
|
||||
- [ ] `npm run build` passes.
|
||||
- [ ] `npm run payload:migrate:status` shows all migrations as `Yes` on the release database.
|
||||
- [ ] `payload_migrations` has no `batch = -1` dev marker before exporting/importing the database.
|
||||
- [ ] No new user-facing fallback copy was introduced without a CMS field.
|
||||
|
||||
## Environment
|
||||
|
||||
- [ ] `NEXT_PUBLIC_SITE_URL` is the final public domain.
|
||||
- [ ] `PAYLOAD_SECRET` is set and is not `change-me` or the example value.
|
||||
- [ ] `DATABASE_URI` points to the production PostgreSQL database.
|
||||
- [ ] `PAYLOAD_DB_PUSH=false` is set.
|
||||
- [ ] `PAYLOAD_RUN_MIGRATIONS_ON_START=false` is set unless a one-off controlled migration boot is intentionally planned.
|
||||
- [ ] Upload directories `media/` and `files/` are persistent and backed up.
|
||||
|
||||
## CMS Data
|
||||
|
||||
- [ ] English and Chinese homepage content render.
|
||||
- [ ] Product list and at least one product detail page render in both locales.
|
||||
- [ ] Downloads list and one download detail page render.
|
||||
- [ ] News list and one news detail page render.
|
||||
- [ ] Reviews page tabs render, with local media covers where present.
|
||||
- [ ] Support page and video tutorials render, with local media covers.
|
||||
- [ ] Dealers page map visibility matches CMS setting.
|
||||
|
||||
## Smoke Routes
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run smoke -- "$NEXT_PUBLIC_SITE_URL"
|
||||
```
|
||||
|
||||
Required manual spot checks:
|
||||
|
||||
- `/en`
|
||||
- `/zh`
|
||||
- `/en/products/dmp-a10`
|
||||
- `/en/reviews?tab=reviews`
|
||||
- `/en/support`
|
||||
- `/en/support/tutorial`
|
||||
- `/admin`
|
||||
- `/api/health`
|
||||
|
||||
## Launch Notes
|
||||
|
||||
- Keep old production database and upload volume snapshots until the new release has passed smoke checks and editorial QA.
|
||||
- If schema push or migration prompts appear during app startup, stop. Production startup should not be running schema push or migrations.
|
||||
- If media thumbnails are missing, verify the upload volume before changing CMS records.
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname,
|
||||
})
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'.next/**',
|
||||
'.next-dev/**',
|
||||
'.turbo/**',
|
||||
'.claude/**',
|
||||
'.tmp/**',
|
||||
'media/**',
|
||||
'files/**',
|
||||
'node_modules/**',
|
||||
'public/src/**',
|
||||
'src/seed/raw/**',
|
||||
'src/seed/structured/**',
|
||||
'src/seed/normalized/**',
|
||||
'src/payload-types.ts',
|
||||
],
|
||||
},
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
+4
-1
@@ -10,9 +10,12 @@
|
||||
"build": "next build && node ./src/scripts/fix-next-main-app.mjs",
|
||||
"start": "next start",
|
||||
"smoke": "node ./src/scripts/smoke-check.mjs",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src next.config.mjs tailwind.config.ts --max-warnings=0",
|
||||
"generate:types": "payload generate:types",
|
||||
"payload": "payload",
|
||||
"payload:migrate": "payload migrate",
|
||||
"payload:migrate:status": "payload migrate:status",
|
||||
"seed:fetch-legacy": "node ./src/seed/fetch-legacy-html.mjs",
|
||||
"seed:parse-legacy": "node ./src/seed/parse-legacy-html.mjs",
|
||||
"seed:extract-structured": "node ./src/seed/extract-structured-data.mjs",
|
||||
|
||||
@@ -164,7 +164,7 @@ async function getContactUsPageData(locale: Locale) {
|
||||
return payload.findGlobal({
|
||||
depth: 1,
|
||||
locale,
|
||||
slug: 'contactUsPage' as any,
|
||||
slug: 'contactUsPage',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ async function getDownloadsPageSettings(locale: Locale): Promise<DownloadsPageSe
|
||||
return (await payload.findGlobal({
|
||||
depth: 1,
|
||||
locale,
|
||||
slug: 'downloadsPage' as any,
|
||||
slug: 'downloadsPage',
|
||||
})) as DownloadsPageSettings
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { isLocale, locales } from '@/i18n/config'
|
||||
import { isLocale } from '@/i18n/config'
|
||||
import { CookieConsent } from '@/components/site/cookie-consent'
|
||||
import { ScrollToTop } from '@/components/site/scroll-to-top'
|
||||
import { SiteHeader } from '@/components/site/site-header'
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ProductContentTabs } from './product-content-tabs'
|
||||
import { ProductGallerySection } from './product-gallery-section'
|
||||
import { ProductTemplateMount } from './product-template-mount'
|
||||
import { ProductVideosSection } from './product-videos-section'
|
||||
import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview, ProductVideo } from '@/payload-types'
|
||||
import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview } from '@/payload-types'
|
||||
|
||||
export const revalidate = 300
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ export default async function ProductsPage({
|
||||
const payload = await getPayloadClient()
|
||||
const page = await payload.findGlobal({
|
||||
depth: 1,
|
||||
slug: 'productsPage' as any,
|
||||
slug: 'productsPage',
|
||||
locale,
|
||||
})
|
||||
const categories = await payload.find({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayloadClient } from '@/lib/payload'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const payload = await getPayloadClient()
|
||||
await payload.find({
|
||||
collection: 'products',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: 'eversoloweb',
|
||||
database: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
service: 'eversoloweb',
|
||||
database: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown health check failure',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,6 @@ function ProductImage({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={cardImageUrl}
|
||||
alt={getProductDisplayAlt(product)}
|
||||
@@ -101,7 +100,6 @@ function ProductImage({
|
||||
].join(' ')}
|
||||
/>
|
||||
{lifestyleImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={lifestyleImageUrl}
|
||||
alt={getProductDisplayAlt(product)}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function HomeHeroBanners({ items, locale }: Props) {
|
||||
const isExternal = isExternalHref(item.href)
|
||||
const content = (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{ }
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.alt}
|
||||
|
||||
@@ -57,7 +57,6 @@ export function HomeHero({
|
||||
playsInline
|
||||
/>
|
||||
) : heroImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt={headline || 'Eversolo'}
|
||||
|
||||
@@ -46,7 +46,6 @@ export function HomeNews({ items, locale }: Props) {
|
||||
className="block overflow-hidden bg-[linear-gradient(135deg,#e7e0d4_0%,#f7f4ee_100%)]"
|
||||
>
|
||||
{item.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
|
||||
@@ -61,7 +61,6 @@ export function HomeRecognition({
|
||||
const content = (
|
||||
<div className="flex min-h-[84px] min-w-[220px] items-center gap-4 px-5 py-4 md:min-w-[250px] md:px-6">
|
||||
{mediaUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={award.label || ''}
|
||||
@@ -111,7 +110,6 @@ export function HomeRecognition({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(180deg,#f3efe8_0%,#ece7df_100%)]">
|
||||
{mediaUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={sourceName}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function HomeStatement({
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(190,169,129,0.18),transparent_32%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.05),transparent_26%)]" />
|
||||
{imageUrl ? (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 hidden w-[42%] overflow-hidden lg:block">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{ }
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title || 'Eversolo'}
|
||||
|
||||
+11
-2
@@ -44,6 +44,15 @@ const dirname = path.dirname(filename)
|
||||
const shouldPushSchema =
|
||||
process.env.PAYLOAD_DB_PUSH === 'true' ||
|
||||
(process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_DB_PUSH !== 'false')
|
||||
const shouldRunProdMigrationsOnStart = process.env.PAYLOAD_RUN_MIGRATIONS_ON_START === 'true'
|
||||
const payloadSecret = process.env.PAYLOAD_SECRET
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
(!payloadSecret || payloadSecret === 'change-me' || payloadSecret === 'replace-me-with-a-long-random-string')
|
||||
) {
|
||||
throw new Error('PAYLOAD_SECRET must be set to a strong production value before starting Payload.')
|
||||
}
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
@@ -108,7 +117,7 @@ export default buildConfig({
|
||||
defaultLocale: 'en',
|
||||
fallback: false,
|
||||
},
|
||||
secret: process.env.PAYLOAD_SECRET ?? 'change-me',
|
||||
secret: payloadSecret ?? 'development-payload-secret',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
@@ -118,7 +127,7 @@ export default buildConfig({
|
||||
},
|
||||
migrationDir: path.resolve(dirname, 'migrations'),
|
||||
push: shouldPushSchema,
|
||||
prodMigrations: migrations,
|
||||
prodMigrations: shouldRunProdMigrationsOnStart ? migrations : undefined,
|
||||
}),
|
||||
upload: {
|
||||
limits: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export function HtmlPreviewTextareaField(props: TextareaFieldClientProps) {
|
||||
aria-selected={mode === 'html'}
|
||||
className="html-preview-textarea__tab"
|
||||
onClick={() => setMode('html')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
HTML
|
||||
@@ -47,6 +48,7 @@ export function HtmlPreviewTextareaField(props: TextareaFieldClientProps) {
|
||||
aria-selected={mode === 'preview'}
|
||||
className="html-preview-textarea__tab"
|
||||
onClick={() => setMode('preview')}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
Preview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CollectionConfig, Field } from 'payload'
|
||||
import type { CollectionConfig, Field, Payload, Where } from 'payload'
|
||||
import { getProductTemplateOptions } from '@/lib/admin-template-options'
|
||||
import { compactArrayAdmin } from '@/payload/admin/compact-array-admin'
|
||||
import { independentDocListFilter } from '@/payload/admin/localized-list-filter'
|
||||
@@ -31,7 +31,7 @@ const seoFields: Field[] = [
|
||||
async function preventDuplicateProductIdentity(args: {
|
||||
data?: Record<string, unknown> | null
|
||||
originalDoc?: Record<string, unknown> | null
|
||||
req?: { payload?: any }
|
||||
req?: { payload?: Payload }
|
||||
}) {
|
||||
const { data, originalDoc, req } = args
|
||||
if (!data || !req?.payload) return data
|
||||
@@ -40,7 +40,7 @@ async function preventDuplicateProductIdentity(args: {
|
||||
const model = typeof data.model === 'string' ? data.model.trim() : typeof originalDoc?.model === 'string' ? originalDoc.model.trim() : ''
|
||||
const slug = typeof data.slug === 'string' ? data.slug.trim() : typeof originalDoc?.slug === 'string' ? originalDoc.slug.trim() : ''
|
||||
const currentId = originalDoc?.id
|
||||
const duplicateChecks = []
|
||||
const duplicateChecks: Where[] = []
|
||||
|
||||
if (docLocale && model) {
|
||||
duplicateChecks.push({ model: { equals: model } })
|
||||
|
||||
@@ -18,8 +18,16 @@ const routes = [
|
||||
'/zh/dealers',
|
||||
'/en/dealers?tab=stores',
|
||||
'/zh/dealers?tab=stores',
|
||||
'/en/reviews',
|
||||
'/en/reviews?tab=reviews',
|
||||
'/zh/reviews',
|
||||
'/en/support',
|
||||
'/zh/support',
|
||||
'/en/support/contact',
|
||||
'/zh/support/contact',
|
||||
'/en/support/tutorial',
|
||||
'/zh/support/tutorial',
|
||||
'/api/health',
|
||||
'/favicon.ico',
|
||||
'/Product/index/model/DAC-Z10/target/X4C68nRijzjeq7k9e%5Bld%5D3ulg%3D%3D.html',
|
||||
]
|
||||
@@ -40,7 +48,10 @@ async function check(route) {
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(routes.map((route) => check(route)))
|
||||
const results = []
|
||||
for (const route of routes) {
|
||||
results.push(await check(route))
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const suffix = result.location ? ` -> ${result.location}` : ''
|
||||
|
||||
Reference in New Issue
Block a user