仍然有bug

This commit is contained in:
龟男日记\www 2026-02-27 05:49:21 +08:00
parent 1f03387619
commit 0fc2899f25
13 changed files with 683 additions and 163 deletions

View File

@ -23,6 +23,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelatedProductsField as RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426 } from '../../../components/fields/RelatedProductsField'
import { ProductOrdersField as ProductOrdersField_d2dd8a14d02b2830d96686a022683d02 } from '../../../components/fields/ProductOrdersField'
import { SeedProjectStatusesButton as SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62 } from '../../../components/seed/SeedProjectStatusesButton'
import { TaobaoProductSync as TaobaoProductSync_c920a85a41a3caf5464668c331ea204a } from '../../../components/sync/taobao/TaobaoProductSync'
import { TaobaoFetchButton as TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7 } from '../../../components/fields/TaobaoFetchButton'
import { TaobaoLinkPreview as TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959 } from '../../../components/fields/TaobaoLinkPreview'
@ -38,6 +39,7 @@ import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b
import { DisassemblyEditorCell as DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07 } from '../../../components/cells/DisassemblyEditorCell'
import { SeedDisassemblyButton as SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9 } from '../../../components/seed/SeedDisassemblyButton'
import { default as default_8aadec319652639fb5e982d94aabed6c } from '../../../components/views/Disassembly/DisassemblyPageSaveArea'
import { SeedPrecautionsButton as SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb } from '../../../components/seed/SeedPrecautionsButton'
import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel'
import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView'
import { RestoreRecommendationsSeedButton as RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da } from '../../../components/seed/RestoreRecommendationsSeedButton'
@ -71,6 +73,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/components/fields/RelatedProductsField#RelatedProductsField": RelatedProductsField_f3e26ca26ab1ef52a2ee0f6932180426,
"/components/fields/ProductOrdersField#ProductOrdersField": ProductOrdersField_d2dd8a14d02b2830d96686a022683d02,
"/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton": SeedProjectStatusesButton_2d6200f8d9c4e1d9630f4ca2e1ecad62,
"/components/sync/taobao/TaobaoProductSync#TaobaoProductSync": TaobaoProductSync_c920a85a41a3caf5464668c331ea204a,
"/components/fields/TaobaoFetchButton#TaobaoFetchButton": TaobaoFetchButton_6da2c7669760b5ece28f442df13318c7,
"/components/fields/TaobaoLinkPreview#TaobaoLinkPreview": TaobaoLinkPreview_44c9439e828c0463191af62d21ad4959,
@ -86,6 +89,7 @@ export const importMap = {
"/components/cells/DisassemblyEditorCell#DisassemblyEditorCell": DisassemblyEditorCell_9a55a4325c8609b6661772e6b79baf07,
"/components/seed/SeedDisassemblyButton#SeedDisassemblyButton": SeedDisassemblyButton_856cd11a2fe6ac8ad5696108a79fdfb9,
"/components/views/Disassembly/DisassemblyPageSaveArea#default": default_8aadec319652639fb5e982d94aabed6c,
"/components/seed/SeedPrecautionsButton#SeedPrecautionsButton": SeedPrecautionsButton_768e87b00d261fe69a4b4731c1e8e2fb,
"/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4,
"/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f,
"/components/seed/RestoreRecommendationsSeedButton#RestoreRecommendationsSeedButton": RestoreRecommendationsSeedButton_ebef550e255346daa9e9f2a11698b0da,

View File

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { addCorsHeaders, handleCorsOptions } from '@/lib/cors'
export async function OPTIONS(request: Request) {
const origin = request instanceof Request ? request.headers.get('origin') : null
return handleCorsOptions(origin)
}
/**
* Tab
* GET /api/products/:id/content[?collection=products|preorder-products]
*
* :id
* - Payload ID
* - Medusa ID (medusaId)
* - Seed ID (seedId)
* - Handle (handle)
*
* : { id, handle, content, projectStatuses[], precautions[], sharedPrecautions[] }
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const origin = req.headers.get('origin')
try {
const payload = await getPayload({ config })
const { id } = await params
const collectionParam = req.nextUrl.searchParams.get('collection')
// Determine which collections to search
const collections: Array<'products' | 'preorder-products'> =
collectionParam === 'preorder-products' ? ['preorder-products']
: collectionParam === 'products' ? ['products']
: ['products', 'preorder-products']
let doc: any = null
for (const collection of collections) {
// 1. Try as Payload document ID
try {
doc = await payload.findByID({ collection, id, depth: 2 })
if (doc) break
} catch {
// not a valid Payload ID — fall through
}
// 2. Try medusaId / seedId / handle
const result = await payload.find({
collection,
where: {
or: [
{ medusaId: { equals: id } },
{ seedId: { equals: id } },
{ handle: { equals: id } },
],
},
limit: 1,
depth: 2,
})
if (result.docs.length > 0) {
doc = result.docs[0]
break
}
}
if (!doc) {
const res = NextResponse.json({ error: 'Product not found' }, { status: 404 })
return addCorsHeaders(res, origin)
}
// Normalise sharedPrecautions: depth:2 resolves relationship to full Precaution docs
const sharedPrecautions = (doc.sharedPrecautions ?? [])
.map((item: any) => {
if (typeof item === 'object' && item !== null && item.id) {
return {
id: item.id as string,
title: item.title as string,
summary: (item.summary as string | undefined) ?? undefined,
}
}
return null
})
.filter(Boolean)
const data = {
id: doc.id,
handle: doc.handle ?? null,
content: doc.content ?? null,
projectStatuses: (doc.projectStatuses ?? []).map((s: any) => ({
id: s.id ?? String(Math.random()),
title: s.title,
badge: s.badge ?? undefined,
description: s.description ?? undefined,
order: s.order ?? 0,
})),
precautions: (doc.precautions ?? []).map((p: any) => ({
id: p.id ?? String(Math.random()),
title: p.title,
summary: p.summary ?? undefined,
order: p.order ?? 0,
})),
sharedPrecautions,
}
const res = NextResponse.json(data)
return addCorsHeaders(res, origin)
} catch (err: any) {
console.error('[products/[id]/content] Error:', err?.message ?? err)
const res = NextResponse.json({ error: 'Internal server error' }, { status: 500 })
return addCorsHeaders(res, origin)
}
}

View File

@ -1,8 +1,13 @@
import type { Field, Tab } from 'payload'
import {
BoldFeature,
HeadingFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
UploadFeature,
FixedToolbarFeature,
InlineToolbarFeature,
@ -305,6 +310,15 @@ export { OrdersTab } from '../project/OrdersTab'
export const ProjectStatusesTab: Tab = {
label: '📊 项目状态',
fields: [
{
name: 'seedProjectStatusesUI',
type: 'ui',
admin: {
components: {
Field: '/components/seed/SeedProjectStatusesButton#SeedProjectStatusesButton',
},
},
},
{
name: 'projectStatuses',
type: 'array',
@ -330,10 +344,20 @@ export const ProjectStatusesTab: Tab = {
},
{
name: 'description',
type: 'text',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
],
}),
admin: {
description: '状态简介',
placeholder: '请输入状态简介...',
description: '状态简介(富文本)',
},
},
{

View File

@ -1,33 +1,26 @@
import type { CollectionConfig } from 'payload'
import { logAfterChange, logAfterDelete } from '../../hooks/logAction'
import {
BoldFeature,
HeadingFeature,
ItalicFeature,
lexicalEditor,
LinkFeature,
OrderedListFeature,
ParagraphFeature,
UnorderedListFeature,
FixedToolbarFeature,
InlineToolbarFeature,
HorizontalRuleFeature,
} from '@payloadcms/richtext-lexical'
import { PrecautionItemFields } from '../base/ProductBase'
/**
*
* 使
* Products PreorderProducts
*
* PrecautionItemFields title / summary / order
* Products / PreorderProducts sharedPrecautions
*/
export const Precautions: CollectionConfig = {
slug: 'precautions',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'type', 'updatedAt'],
description: '管理产品注意事项,如使用须知、安装注意、购买说明等',
defaultColumns: ['title', 'summary', 'updatedAt'],
description: '管理通用注意事项,可被多个产品复用引用',
pagination: {
defaultLimit: 25,
},
components: {
beforeListTable: [
'/components/seed/SeedPrecautionsButton#SeedPrecautionsButton',
],
},
},
access: {
read: () => true,
@ -35,91 +28,7 @@ export const Precautions: CollectionConfig = {
update: ({ req: { user } }) => !!user,
delete: ({ req: { user } }) => !!user,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: ' 基本信息',
fields: [
{
type: 'row',
fields: [
{
name: 'title',
type: 'text',
required: true,
admin: {
description: '注意事项标题,例如:安装注意事项、使用须知',
width: '60%',
},
},
{
name: 'type',
type: 'select',
defaultValue: 'general',
admin: {
description: '注意事项类型',
width: '40%',
},
options: [
{ label: '通用', value: 'general' },
{ label: '安装', value: 'installation' },
{ label: '使用', value: 'usage' },
{ label: '购买', value: 'purchase' },
{ label: '售后', value: 'aftersale' },
{ label: '安全', value: 'safety' },
],
},
],
},
{
name: 'summary',
type: 'text',
admin: {
description: '注意事项摘要(纯文本,用于列表展示)',
placeholder: '请输入注意事项摘要...',
},
},
{
name: 'order',
type: 'number',
defaultValue: 0,
admin: {
description: '排序权重(数值越小越靠前)',
},
},
],
},
{
label: '📄 详细内容',
fields: [
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: [
ParagraphFeature(),
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
BoldFeature(),
ItalicFeature(),
UnorderedListFeature(),
OrderedListFeature(),
LinkFeature(),
HorizontalRuleFeature(),
FixedToolbarFeature(),
InlineToolbarFeature(),
],
}),
admin: {
description: '注意事项详细内容(富文本)',
},
},
],
},
],
},
],
fields: PrecautionItemFields,
hooks: {
afterChange: [logAfterChange],
afterDelete: [logAfterDelete],

View File

@ -0,0 +1,132 @@
'use client'
import React, { useState } from 'react'
import { Button, useConfig } from '@payloadcms/ui'
// ─── seed data ────────────────────────────────────────────────────────────────
const SEED_PRECAUTIONS = [
{
title: 'No Returns',
summary:
'This is a customized product. Due to its personalized nature, we are unable to accept returns once an order has been placed.',
order: 1,
},
{
title: 'Refund Policy — Stripe Processing Fee',
summary:
'Refunds are fully supported. Please note that a Stripe payment processing fee of approximately 5% will be deducted from the refund amount, as this fee is non-recoverable once charged.',
order: 2,
},
{
title: 'Preorder Guarantee',
summary:
'Even if the preorder campaign does not reach its funding goal, you will still receive the corresponding product. Your order will not go unfulfilled.',
order: 3,
},
{
title: 'Exclusive Backer Rewards',
summary:
'Preorder backers will receive exclusive additional rewards as a thank-you for supporting the project early. These bonus items are only available to campaign backers.',
order: 4,
},
]
// ─── component ────────────────────────────────────────────────────────────────
/**
* Displayed before the Precautions list table.
* Creates the 4 standard English precaution records if they do not already exist.
*/
export function SeedPrecautionsButton() {
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const handleSeed = async () => {
if (
!window.confirm(
`Create ${SEED_PRECAUTIONS.length} standard precaution records?\n\n` +
SEED_PRECAUTIONS.map((p) => `${p.title}`).join('\n') +
'\n\nExisting records with identical titles will be skipped.',
)
)
return
setLoading(true)
setResult('')
let created = 0
let skipped = 0
const errors: string[] = []
for (const precaution of SEED_PRECAUTIONS) {
try {
// Check for existing record with the same title
const check = await fetch(
`${apiBase}/precautions?where[title][equals]=${encodeURIComponent(precaution.title)}&limit=1`,
{ credentials: 'include' },
)
const checkData = await check.json()
if (checkData?.totalDocs > 0) {
skipped++
continue
}
const res = await fetch(`${apiBase}/precautions`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(precaution),
})
if (res.ok) {
created++
} else {
const err = await res.json().catch(() => ({}))
errors.push(`${precaution.title}: ${err?.errors?.[0]?.message ?? res.status}`)
}
} catch (e: any) {
errors.push(`${precaution.title}: ${e?.message ?? 'Network error'}`)
}
}
setLoading(false)
const parts: string[] = []
if (created) parts.push(`✓ Created ${created}`)
if (skipped) parts.push(`${skipped} already existed`)
if (errors.length) parts.push(`${errors.length} failed`)
setResult(parts.join(' · ') + (errors.length ? `\n${errors.join('\n')}` : ''))
if (created > 0) setTimeout(() => window.location.reload(), 800)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-3) 0',
}}
>
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
{loading ? 'Creating…' : '⊕ Seed Standard Precautions (EN)'}
</Button>
{result && (
<span
style={{
fontSize: 'var(--font-body-s)',
color: 'var(--theme-elevation-500)',
whiteSpace: 'pre-line',
}}
>
{result}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,150 @@
'use client'
import React, { useState } from 'react'
import { Button, useConfig, useDocumentInfo } from '@payloadcms/ui'
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Wrap plain text in minimal Lexical JSON so richText fields accept it */
function lexicalParagraph(text: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [{ type: 'text', format: 0, text, version: 1 }],
},
],
},
}
}
// ─── sample data ──────────────────────────────────────────────────────────────
const SAMPLE_STATUSES = [
{
title: 'In Development',
badge: 'Dev',
description: lexicalParagraph(
'Hardware prototyping and firmware development are actively in progress.',
),
order: 1,
},
{
title: 'Crowdfunding Live',
badge: 'Live',
description: lexicalParagraph(
'The crowdfunding campaign is now live and open to backers.',
),
order: 2,
},
{
title: 'Mass Production',
badge: 'Producing',
description: lexicalParagraph(
'Funding goal reached. Manufacturing has begun and is on schedule.',
),
order: 3,
},
{
title: 'Shipping',
badge: 'Shipping',
description: lexicalParagraph(
'Orders are being fulfilled and packages are on their way to backers.',
),
order: 4,
},
]
// ─── component ────────────────────────────────────────────────────────────────
/**
* Displayed as a UI field above the projectStatuses array in the product form.
* Appends sample statuses to whatever already exists, then reloads the page.
*/
export function SeedProjectStatusesButton() {
const { id: docId, collectionSlug } = useDocumentInfo()
const { config } = useConfig()
const apiBase: string = (config as any)?.routes?.api ?? '/api'
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const handleSeed = async () => {
if (!docId) {
setResult('Save the document first before adding sample data.')
return
}
if (
!window.confirm(
`Append ${SAMPLE_STATUSES.length} sample project statuses to this product?\n\n` +
SAMPLE_STATUSES.map((s) => `${s.title} [${s.badge}]`).join('\n'),
)
)
return
setLoading(true)
setResult('')
try {
// Fetch current doc to get existing statuses so we don't overwrite them
const getRes = await fetch(`${apiBase}/${collectionSlug}/${docId}?depth=0`, {
credentials: 'include',
})
const doc = await getRes.json()
const existing: any[] = doc.projectStatuses ?? []
const baseOrder = existing.length
const merged = [
...existing,
...SAMPLE_STATUSES.map((s, i) => ({ ...s, order: baseOrder + i + 1 })),
]
const patchRes = await fetch(`${apiBase}/${collectionSlug}/${docId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectStatuses: merged }),
})
if (patchRes.ok) {
setResult(`✓ Added ${SAMPLE_STATUSES.length} sample statuses`)
setTimeout(() => window.location.reload(), 700)
} else {
const err = await patchRes.json().catch(() => ({}))
setResult(`${err?.errors?.[0]?.message ?? patchRes.status}`)
}
} catch (e: any) {
setResult(`${e?.message ?? 'Network error'}`)
}
setLoading(false)
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-3)',
padding: 'var(--spacing-2) 0 var(--spacing-3)',
}}
>
<Button buttonStyle="secondary" size="small" disabled={loading} onClick={handleSeed}>
{loading ? 'Adding…' : '⊕ Insert Sample Statuses'}
</Button>
{result && (
<span style={{ fontSize: 'var(--font-body-s)', color: 'var(--theme-elevation-500)' }}>
{result}
</span>
)}
</div>
)
}

View File

@ -0,0 +1,43 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Final precautions cleanup:
* 1. Remove orphan relationship entries pointing to non-existent precaution IDs
* 2. Drop the leftover `type` and `content` columns simplified schema now just
* uses PrecautionItemFields (title / summary / order), same as the inline array
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- Remove stale sharedPrecautions refs from products
DELETE FROM "products_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
-- Remove stale sharedPrecautions refs from preorder-products
DELETE FROM "preorder_products_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
-- Remove stale locked-document refs
DELETE FROM "payload_locked_documents_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
-- Drop leftover columns from previous complex schema (no longer needed)
ALTER TABLE "precautions" DROP COLUMN IF EXISTS "type";
ALTER TABLE "precautions" DROP COLUMN IF EXISTS "content";
-- Drop the now-unused enum type
DROP TYPE IF EXISTS "public"."enum_precautions_type";
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Re-add the enum and columns if rolling back
await db.execute(sql`
CREATE TYPE IF NOT EXISTS "public"."enum_precautions_type"
AS ENUM('general','installation','usage','purchase','aftersale','safety');
ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "type" "enum_precautions_type" DEFAULT 'general';
ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "content" jsonb;
`)
}

View File

@ -1,45 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Fix precautions ID type mismatch.
*
* The `precautions` table was created by a manual migration with `serial`
* (integer) PKs. Payload v3 / @payloadcms/db-postgres defaults to `text`
* (varchar) IDs. On dev-mode startup, Payload's schema-push tries to ALTER
* the `precautions_id` relationship columns to varchar, but the FK constraint
* pointing to an integer PK blocks it.
*
* Fix: drop the precautions table and all `precautions_id` rels columns so
* Payload's dev-mode push can recreate everything with the correct types.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- Drop FK constraints first (payload_locked_documents_rels)
ALTER TABLE "payload_locked_documents_rels"
DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_precautions_fk";
-- Drop the precautions_id column from payload_locked_documents_rels
ALTER TABLE "payload_locked_documents_rels"
DROP COLUMN IF EXISTS "precautions_id";
-- Drop from products_rels (sharedPrecautions relationship)
ALTER TABLE "products_rels"
DROP CONSTRAINT IF EXISTS "products_rels_precautions_fk";
ALTER TABLE "products_rels"
DROP COLUMN IF EXISTS "precautions_id";
-- Drop from preorder_products_rels (sharedPrecautions relationship)
ALTER TABLE "preorder_products_rels"
DROP CONSTRAINT IF EXISTS "preorder_products_rels_precautions_fk";
ALTER TABLE "preorder_products_rels"
DROP COLUMN IF EXISTS "precautions_id";
-- Drop the precautions table itself (Payload will recreate with varchar PK)
DROP TABLE IF EXISTS "precautions" CASCADE;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Recreating the integer-based schema is not worth reverting to since
// the whole point is to migrate to the correct varchar-based schema.
}

View File

@ -0,0 +1,49 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Nuclear cleanup: remove ALL precautions_id relationship entries from rels tables,
* then verify precautions table is healthy.
*
* Why: after multiple DROP+CREATE migrations, any previously-saved sharedPrecautions
* values in products_rels / preorder_products_rels may reference IDs from an
* earlier incarnation of the table. Rather than keep patching, clear the slate so
* users can re-link from the admin UI with the current valid IDs.
*/
export async function up({ db, payload }: MigrateUpArgs): Promise<void> {
// Log current state before cleaning
const before = await db.execute(sql`
SELECT
(SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs,
(SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs,
(SELECT count(*) FROM precautions) AS precaution_count
`)
payload.logger.info({ msg: 'Before cleanup', state: (before as any).rows?.[0] ?? before })
// Remove all stale precautions refs (any ID not present in precautions table)
await db.execute(sql`
DELETE FROM "products_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
DELETE FROM "preorder_products_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
DELETE FROM "payload_locked_documents_rels"
WHERE "precautions_id" IS NOT NULL
AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
`)
// Log what's left
const after = await db.execute(sql`
SELECT
(SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs,
(SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs,
(SELECT count(*) FROM precautions) AS precaution_count
`)
payload.logger.info({ msg: 'After cleanup', state: (after as any).rows?.[0] ?? after })
}
export async function down(_args: MigrateDownArgs): Promise<void> {
// Nothing to undo — deleted relationship rows cannot be restored
}

View File

@ -1,5 +1,7 @@
import * as migration_baseline from './baseline'
import * as migration_fix_precautions_id_type from './fix_precautions_id_type'
import * as migration_baseline from './baseline';
import * as migration_project_statuses_description_to_jsonb from './project_statuses_description_to_jsonb';
import * as migration_cleanup_precautions from './cleanup_precautions';
import * as migration_fix_stale_precaution_rels from './fix_stale_precaution_rels';
export const migrations = [
{
@ -8,9 +10,18 @@ export const migrations = [
name: 'baseline',
},
{
up: migration_fix_precautions_id_type.up,
down: migration_fix_precautions_id_type.down,
name: 'fix_precautions_id_type',
up: migration_project_statuses_description_to_jsonb.up,
down: migration_project_statuses_description_to_jsonb.down,
name: 'project_statuses_description_to_jsonb',
},
]
{
up: migration_cleanup_precautions.up,
down: migration_cleanup_precautions.down,
name: 'cleanup_precautions',
},
{
up: migration_fix_stale_precaution_rels.up,
down: migration_fix_stale_precaution_rels.down,
name: 'fix_stale_precaution_rels',
},
];

View File

@ -0,0 +1,99 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Convert projectStatuses.description from text jsonb.
*
* Payload's schema-push cannot ALTER text jsonb without an explicit USING
* clause because Postgres has no implicit cast between the two types.
*
* Strategy for existing rows:
* - NULL / empty NULL
* - Already JSON cast directly (description::jsonb)
* - Plain text wrap in a minimal Lexical paragraph node so the
* content is preserved and renderable in the admin editor
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
-- products_project_statuses
ALTER TABLE "products_project_statuses"
ALTER COLUMN "description" SET DATA TYPE jsonb
USING CASE
WHEN description IS NULL OR trim(description) = ''
THEN NULL
WHEN left(trim(description), 1) = '{'
THEN description::jsonb
ELSE
jsonb_build_object(
'root', jsonb_build_object(
'type', 'root',
'version', 1,
'format', '',
'indent', 0,
'children', jsonb_build_array(
jsonb_build_object(
'type', 'paragraph',
'version', 1,
'format', '',
'indent', 0,
'children', jsonb_build_array(
jsonb_build_object(
'type', 'text',
'text', description,
'version', 1,
'format', 0
)
)
)
)
)
)
END;
-- preorder_products_project_statuses
ALTER TABLE "preorder_products_project_statuses"
ALTER COLUMN "description" SET DATA TYPE jsonb
USING CASE
WHEN description IS NULL OR trim(description) = ''
THEN NULL
WHEN left(trim(description), 1) = '{'
THEN description::jsonb
ELSE
jsonb_build_object(
'root', jsonb_build_object(
'type', 'root',
'version', 1,
'format', '',
'indent', 0,
'children', jsonb_build_array(
jsonb_build_object(
'type', 'paragraph',
'version', 1,
'format', '',
'indent', 0,
'children', jsonb_build_array(
jsonb_build_object(
'type', 'text',
'text', description,
'version', 1,
'format', 0
)
)
)
)
)
)
END;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "products_project_statuses"
ALTER COLUMN "description" SET DATA TYPE text
USING (description->>'root')::text;
ALTER TABLE "preorder_products_project_statuses"
ALTER COLUMN "description" SET DATA TYPE text
USING (description->>'root')::text;
`)
}

View File

@ -338,9 +338,23 @@ export interface Product {
*/
badge?: string | null;
/**
*
*
*/
description?: string | null;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
/**
*
*/
@ -560,9 +574,23 @@ export interface PreorderProduct {
*/
badge?: string | null;
/**
*
*
*/
description?: string | null;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
/**
*
*/

View File

@ -16,7 +16,7 @@ import { DisassemblyPages } from './collections/disassembly/DisassemblyPages'
import { DisassemblyAreas } from './collections/disassembly/DisassemblyAreas'
import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents'
import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts'
import { Precautions } from './collections/Precautions'
import { Precautions } from './collections/project/Precautions'
import { AdminSettings } from './globals/AdminSettings'
import { LogsManager } from './globals/LogsManager'
import { HeroSlider } from './globals/HeroSlider'