fix(cms): enforce public publication contract
This commit is contained in:
384
docs/client-feedback-task-list.md
Normal file
384
docs/client-feedback-task-list.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Client feedback implementation task list
|
||||
|
||||
This plan translates the client feedback from `Feedback.xlsx` into implementation tasks for the current Payload CMS and Next.js codebase.
|
||||
|
||||
## Confirmed decisions
|
||||
|
||||
- Unpublishing any draft-enabled CMS document must remove it from the anonymous public frontend.
|
||||
- Authenticated draft/live preview may continue to show unpublished content intentionally.
|
||||
- The CMS cleanup is focused: remove obsolete or non-rendered controls, keep page-specific content on its page, and keep content shared by multiple pages in Globals.
|
||||
- `/netzwerk` receives its own CMS video upload instead of reusing the homepage video.
|
||||
- Images and videos introduced by this work must be selectable through Payload's Media collection.
|
||||
- Sponsor hierarchy needs a responsive design checkpoint before implementation.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- An anonymous visitor cannot see an unpublished page, winner, jury member, partner, event, or press article in a list, relationship-driven section, direct detail route, sitemap, or fallback UI.
|
||||
- An editor can still preview drafts through Payload preview/live preview.
|
||||
- Payload exposes only controls that affect the current frontend or an explicitly retained global contract.
|
||||
- New press and Netzwerk media can be selected and replaced in Payload without developer assistance.
|
||||
- All visual feedback is verified at desktop, tablet, and mobile widths.
|
||||
- Every schema change has a committed migration tested against a copy of the current SQLite database.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
### 1. Establish the publication contract
|
||||
|
||||
This is the highest-priority work because later CMS cleanup must not hide the underlying visibility problem.
|
||||
|
||||
- [ ] Reproduce unpublishing from the Payload admin for every draft-enabled collection:
|
||||
- [ ] `pages`
|
||||
- [ ] `preistraeger`
|
||||
- [ ] `jury-mitglieder`
|
||||
- [ ] `partners`
|
||||
- [ ] `events`
|
||||
- [ ] `posts`
|
||||
- [ ] For every reproduction, record:
|
||||
- [ ] the document ID and `_status` before and after clicking **Unpublish**
|
||||
- [ ] whether the admin request actually saves `_status = draft`
|
||||
- [ ] behavior in an anonymous/private browser session
|
||||
- [ ] behavior in Payload preview or a browser carrying a draft-mode cookie
|
||||
- [ ] behavior on the collection/list page and direct detail URL
|
||||
- [ ] Confirm that the reported problem is not only draft preview doing what it is designed to do.
|
||||
- [ ] Add a table-driven integration test that queries each collection as an anonymous public request and proves that only `_status = published` documents are returned.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Directly unpublishing a document changes its stored `_status` to `draft`.
|
||||
- [ ] Anonymous Local API reads enforce access control with `overrideAccess: false` and `draft: false`.
|
||||
- [ ] Draft preview is the only frontend context allowed to request unpublished documents.
|
||||
|
||||
### 2. Fix public route hydration and relationship resolution
|
||||
|
||||
The public route currently loads the primary document plus supporting collections in `src/app/(frontend)/[[...slug]]/page.tsx`. Make the public/draft boundary explicit rather than relying on implicit parameter combinations.
|
||||
|
||||
- [ ] Split public and preview query options into a small, testable helper:
|
||||
- [ ] public: `draft: false`, `overrideAccess: false`
|
||||
- [ ] preview: drafts enabled only when Next draft mode is active
|
||||
- [ ] Apply the same contract to:
|
||||
- [ ] the route document
|
||||
- [ ] support pages
|
||||
- [ ] winners
|
||||
- [ ] events
|
||||
- [ ] press articles
|
||||
- [ ] partners
|
||||
- [ ] jury members
|
||||
- [ ] Ensure relationship-populated objects cannot bypass the filtered public collection.
|
||||
- [ ] Update `resolveCmsRelationship(...)` and/or the consuming selection helpers so a relationship ID missing from the published collection resolves to nothing, never to the embedded relationship object.
|
||||
- [ ] Keep the existing jury page selection authoritative, but silently omit selected jury members that are unpublished.
|
||||
- [ ] Add regression tests for:
|
||||
- [ ] unpublished jury member selected on `/netzwerk`
|
||||
- [ ] unpublished winner selected on the homepage or `/der-bmp`
|
||||
- [ ] unpublished partner previously present in the homepage ticker
|
||||
- [ ] unpublished event previously present on `/presse`
|
||||
- [ ] unpublished press article previously present on `/presse`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Unpublishing one related item removes only that item and preserves the remaining published selection order.
|
||||
- [ ] Republishing the item restores it without requiring a page document edit.
|
||||
|
||||
### 3. Remove collection fallbacks that resurrect unpublished content
|
||||
|
||||
Fallbacks for a missing image or missing optional field may remain. Entire fallback records must not replace intentionally empty CMS collections.
|
||||
|
||||
- [ ] Change `/presse` so an empty published `events` collection renders an intentional empty state or no event rows instead of `pressIndex.events.fallbackItems`.
|
||||
- [ ] Change `/presse` so an empty published `posts` collection renders an intentional empty state or no news cards instead of `pressIndex.news.fallbackItems`.
|
||||
- [ ] Remove the event and news fallback-record arrays from `pressIndexFields.ts` after the collection-backed render path is authoritative.
|
||||
- [ ] Remove their preload/seed plumbing from `preload-press-index-page-cms.ts` and fresh-database setup.
|
||||
- [ ] Audit winner, jury, and partner surfaces for the same pattern.
|
||||
- [ ] Preserve only presentation fallbacks that cannot make a removed CMS record reappear, such as a placeholder image on an otherwise published document.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Unpublishing the final event, article, or related record produces an honest empty state.
|
||||
- [ ] No demo/default record appears merely because the CMS collection became empty.
|
||||
|
||||
### 4. Complete the focused Payload CMS cleanup
|
||||
|
||||
Create a field-to-renderer inventory before deleting schema. Every retained field must have a current frontend consumer or a documented global purpose.
|
||||
|
||||
#### Startseite
|
||||
|
||||
- [ ] Keep the page-specific hero, winners, quick check, intro, testimonials, benefits, and homepage video controls.
|
||||
- [ ] Remove the hidden legacy `home.application` group now owned by the `application-form` Global.
|
||||
- [ ] Remove the hidden legacy `home.form` group now owned by the `application-form` Global.
|
||||
|
||||
#### Teilnahme
|
||||
|
||||
- [ ] Keep hero, process, eligibility, application paths, dates, and awards.
|
||||
- [ ] Remove the hidden legacy `participation.applicationForm` group.
|
||||
- [ ] Remove the hidden legacy `participation.form` group.
|
||||
|
||||
#### Der BMP
|
||||
|
||||
- [ ] Remove the hidden legacy `about.highlights` group because the rendered winners section reuses the homepage source of truth.
|
||||
|
||||
#### Presse
|
||||
|
||||
- [ ] Remove obsolete press-kit controls that no longer render:
|
||||
- [ ] `kitLabel`
|
||||
- [ ] `kitMeta`
|
||||
- [ ] `kitFile`
|
||||
- [ ] `kitUrl`
|
||||
- [ ] `kitDownloadName`
|
||||
- [ ] Remove the complete non-rendered `pressIndex.accreditation` group.
|
||||
- [ ] Remove the fallback-record arrays covered by Task 3.
|
||||
- [ ] Keep the rendered section headings, descriptions, status labels, missing-image controls, and press-contact copy.
|
||||
|
||||
#### Netzwerk
|
||||
|
||||
- [ ] Remove confirmed non-rendered fields after the field-to-renderer audit, including current candidates:
|
||||
- [ ] `jury.note`
|
||||
- [ ] `partners.premiumLabel`
|
||||
- [ ] `partners.defaultLogoColor`
|
||||
- [ ] sponsoring membership-footnote fields
|
||||
- [ ] Do not remove partner tier labels, partner modal copy, jury selection, or sponsoring form copy that still renders.
|
||||
|
||||
#### Global ownership
|
||||
|
||||
- [ ] Keep shared application content in **Formulare > Bewerbungsformular**.
|
||||
- [ ] Keep phase state in the **Application Phase** Global.
|
||||
- [ ] Keep phase-dependent newsletter content in the **Newsletter-Formular** Global.
|
||||
- [ ] Keep Header, Footer, and site-wide settings in their existing Website Globals.
|
||||
- [ ] Do not create a generic “Sections” content type unless a second real consumer justifies it.
|
||||
|
||||
#### Schema completion
|
||||
|
||||
- [ ] Generate a narrow migration that removes the obsolete live-table and version-table columns/child tables.
|
||||
- [ ] Update `payload-types.ts`.
|
||||
- [ ] Update preload, seed, and fresh-database scripts.
|
||||
- [ ] Validate migration upgrade and rollback on a copied SQLite database.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] The Payload admin contains no hidden legacy groups or controls with no current frontend effect.
|
||||
- [ ] Shared content is edited once in a Global and is not duplicated on individual pages.
|
||||
- [ ] Page editors contain only page-specific content plus standard page metadata/SEO controls.
|
||||
|
||||
### 5. Give `/netzwerk` its own Ilse Aigner video
|
||||
|
||||
- [ ] Add a video upload field under the Netzwerk patronage/greeting section.
|
||||
- [ ] Filter the upload relationship to video MIME types.
|
||||
- [ ] Add page-specific video button label, title, description, and close-label fields if those strings need to differ from the homepage.
|
||||
- [ ] Replace the `HomeVideoCms`/homepage lookup in `Netzwerk.tsx` with the Netzwerk page fields.
|
||||
- [ ] Keep the existing homepage video and modal completely independent.
|
||||
- [ ] Update preload/fresh-database code without copying the existing homepage media relationship into the new field unless the client explicitly wants a temporary placeholder.
|
||||
- [ ] Let the client upload and select the supplied Netzwerk video after deployment.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Changing the homepage video does not change `/netzwerk`.
|
||||
- [ ] Changing the Netzwerk video does not change the homepage.
|
||||
- [ ] Empty Netzwerk video selection hides the video action cleanly.
|
||||
|
||||
### 6. Add the CMS-controlled press-contact image
|
||||
|
||||
- [ ] Add `contactImage` and `contactImageAlt` to the rendered Presse contact/material section.
|
||||
- [ ] Use a Payload upload relationship filtered to image MIME types.
|
||||
- [ ] Restore the desktop contact section to a two-column composition with copy on the left and the selected image on the right.
|
||||
- [ ] Stack image and copy in a deliberate order on mobile.
|
||||
- [ ] Define a safe empty-image layout so the section still works before the client selects an asset.
|
||||
- [ ] Update the preload script and migration.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] The client can select, replace, or clear the image in Payload.
|
||||
- [ ] No code edit is required to change the press image.
|
||||
|
||||
### 7. Repair the `/teilnahme` “Schritt für Schritt” timeline
|
||||
|
||||
The current desktop implementation uses a `300vh` scroll container and produces overlapping/off-screen cards.
|
||||
|
||||
- [ ] Replace the current per-card transform calculation with a single deterministic horizontal-track transform, or replace scroll-jacking with an explicit horizontal carousel/stepper.
|
||||
- [ ] Ensure steps 1–4 are all reachable at normal wheel/trackpad speed.
|
||||
- [ ] Remove the unused vertical space after the final step.
|
||||
- [ ] Make the progress indicator derive from actual scroll/active-step state instead of fixed copy.
|
||||
- [ ] Preserve the existing stacked mobile presentation unless testing finds a separate defect.
|
||||
- [ ] Respect `prefers-reduced-motion` by providing a non-scroll-jacked interaction.
|
||||
- [ ] Add a browser regression test that proves every step becomes visible and the next section follows without a large blank gap.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Steps 1, 2, 3, and 4 can each be read in full.
|
||||
- [ ] Cards never overlap at supported desktop/tablet widths.
|
||||
- [ ] The eligibility section begins promptly after the timeline finishes.
|
||||
|
||||
### 8. Design and approve a responsive sponsor hierarchy
|
||||
|
||||
Recommended direction: preserve a hierarchy through tier bands and decreasing visual weight rather than forcing lower tiers into empty higher-tier slots. Mixing tiers within one row weakens the pyramid and produces inconsistent card widths.
|
||||
|
||||
#### Proposed responsive behavior
|
||||
|
||||
- [ ] Desktop (`>= 1200px`):
|
||||
- [ ] Tier 1: up to 2 large cards per centered row
|
||||
- [ ] Tier 2: up to 3 medium cards per centered row
|
||||
- [ ] Tier 3: up to 4 compact cards per centered row
|
||||
- [ ] Center incomplete rows rather than filling them with a different tier
|
||||
- [ ] Tablet (`768–1199px`):
|
||||
- [ ] Tier 1: 2 columns
|
||||
- [ ] Tier 2: 2 columns
|
||||
- [ ] Tier 3: 3 columns where space allows, otherwise 2
|
||||
- [ ] Mobile (`< 768px`):
|
||||
- [ ] one clearly labeled section per tier
|
||||
- [ ] Tier 1 rendered as full-width feature cards
|
||||
- [ ] Tier 2 rendered as medium full-width cards
|
||||
- [ ] Tier 3 rendered as compact cards, using two columns only when logos remain readable
|
||||
- [ ] Use card size, logo area, spacing, and tier heading—not color alone—to communicate rank accessibly.
|
||||
- [ ] Keep `partner.tier` and `partner.sortOrder` as the CMS source of truth.
|
||||
|
||||
Approval gate:
|
||||
|
||||
- [ ] Produce desktop, tablet, and mobile mockups using the current partner counts.
|
||||
- [ ] Review the hierarchy with the client before implementing the final grid.
|
||||
- [ ] After approval, implement the chosen breakpoints without changing the partner data model unless the mockup reveals a real need.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Hierarchy is immediately understandable at all three breakpoint classes.
|
||||
- [ ] Adding or removing a partner does not create broken gaps or stretch a card unpredictably.
|
||||
- [ ] Logo aspect ratios remain intact.
|
||||
|
||||
### 9. Make sponsor ordering explicit and manageable
|
||||
|
||||
- [ ] Confirm that `sortOrder` remains authoritative within each tier.
|
||||
- [ ] Show `tier`, `sortOrder`, publication status, and active state in useful Partners admin columns.
|
||||
- [ ] Add validation preventing unsupported tier values outside 1–3.
|
||||
- [ ] Document the current order for client review:
|
||||
1. RSM Ebner Stolz
|
||||
2. WWK
|
||||
3. Radio Gong 96.3
|
||||
4. münchen.tv
|
||||
5. METZLER
|
||||
6. Dr. Wieselhuber & Partner
|
||||
7. Fristads
|
||||
8. New Edge
|
||||
9. Moodtalk
|
||||
10. Deutsche Bank
|
||||
11. Bionorica
|
||||
12. Primus
|
||||
- [ ] Treat an order change as a CMS content operation, not a frontend code change.
|
||||
|
||||
### 10. Slow the homepage sponsor ticker
|
||||
|
||||
- [ ] Replace the fixed `22s` duration with a slower content-aware duration or an agreed slower fixed baseline.
|
||||
- [ ] Keep perceived speed stable as the number of logos changes.
|
||||
- [ ] Pause or substantially reduce motion on hover/focus and respect `prefers-reduced-motion`.
|
||||
- [ ] Verify that duplicating the track still produces a seamless loop.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] The ticker feels calm enough to read individual logos.
|
||||
- [ ] No jump is visible at the loop boundary.
|
||||
|
||||
### 11. Correct the homepage phase-heading line break
|
||||
|
||||
- [ ] Confirm the client copy belongs to the homepage status/phase module.
|
||||
- [ ] Make phase headlines honor explicit CMS newlines.
|
||||
- [ ] Store/render the target split as:
|
||||
|
||||
```text
|
||||
Ein weiteres
|
||||
erfolgreiches Jahr
|
||||
```
|
||||
|
||||
- [ ] Preserve responsive wrapping when translated or edited copy is longer.
|
||||
- [ ] Do not hard-code this one sentence in the component.
|
||||
|
||||
### 12. Adjust the homepage bottom application/newsletter split
|
||||
|
||||
- [ ] Widen the blue left panel on the homepage so the boundary with the gold form panel feels more balanced.
|
||||
- [ ] Scope the ratio change through the existing `source="homepage"` prop unless the client separately approves the same change on `/teilnahme`.
|
||||
- [ ] Recheck text measure, benefit rows, form width, and the image area after changing the ratio.
|
||||
- [ ] Verify both open-application and newsletter phase variants.
|
||||
|
||||
### 13. Verify the shared application/newsletter background color
|
||||
|
||||
The current component already uses dark blue `#24366A` instead of black.
|
||||
|
||||
- [ ] Compare the deployed site with the current source.
|
||||
- [ ] If production is still black, deploy the existing dark-blue implementation or identify the production content/style override.
|
||||
- [ ] Make no additional palette change if production already matches the requested dark blue.
|
||||
|
||||
### 14. Improve `/teilnahme` eligibility hierarchy
|
||||
|
||||
- [ ] Keep the existing content and CTAs.
|
||||
- [ ] Introduce clearer grouping within the white eligibility area through restrained section tinting, dividers, spacing, or row containers.
|
||||
- [ ] Avoid turning every criterion into an unrelated floating card.
|
||||
- [ ] Make the visual transition into “Wichtige Termine” clear without redesigning the rest of the page.
|
||||
- [ ] Verify mobile scan order and contrast.
|
||||
|
||||
### 15. Adjust `/der-bmp` “Relevanz & Zielsetzung”
|
||||
|
||||
- [ ] Increase the desktop share of the left image so more of it is visible.
|
||||
- [ ] Shift the right-hand copy farther into the viewport and give it a centered content measure within its panel.
|
||||
- [ ] Tune the image `object-position` rather than cropping important subjects.
|
||||
- [ ] Keep mobile image-first ordering and readable padding.
|
||||
|
||||
### 16. Correct `/der-bmp` “Was ist der Preis?” facts and caption
|
||||
|
||||
- [ ] Center the three key facts within the text panel instead of anchoring each value to the left edge.
|
||||
- [ ] Keep separators and equal visual weight across all three facts.
|
||||
- [ ] Improve the image-caption legibility with a controlled background/overlay or by moving it into a dedicated caption area.
|
||||
- [ ] Verify long CMS labels do not collide.
|
||||
|
||||
### 17. Fix sitemap route generation
|
||||
|
||||
Sitemap support already exists, but current output needs correction.
|
||||
|
||||
- [ ] Generate the homepage URL as `/` when the page slug is either `home` or `startseite`, or prefer the stored `spaPath` directly.
|
||||
- [ ] Generate press article URLs under `/presse/blog/<slug>` instead of `/posts/<slug>`.
|
||||
- [ ] Add event detail URLs under `/presse/events/<slug>`.
|
||||
- [ ] Add winner detail URLs under `/preistraeger/<slug>`.
|
||||
- [ ] Keep unpublished documents out by using public access and `_status = published`.
|
||||
- [ ] Ensure generated sitemap and robots URLs use the production base URL during deployment, never committed localhost/example URLs.
|
||||
- [ ] Add route-level tests for root, page, event, article, and winner entries.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- [ ] Every sitemap URL returns `200` on production.
|
||||
- [ ] Unpublished entries disappear from the sitemap immediately or after the documented cache/revalidation window.
|
||||
|
||||
### 18. Regression, migration, and deployment verification
|
||||
|
||||
- [ ] Run focused unit/integration tests for publication filtering, relationship resolution, and sitemap paths.
|
||||
- [ ] Run TypeScript without incremental cache.
|
||||
- [ ] Run the relevant Vitest suite.
|
||||
- [ ] Build against a migrated copy of `content.db`.
|
||||
- [ ] Visually verify at approximately `1440px`, `1024px`, `768px`, and `390px` widths.
|
||||
- [ ] Test anonymous public browsing separately from authenticated draft preview.
|
||||
- [ ] Back up the production SQLite database.
|
||||
- [ ] Deploy the schema-changing commit with explicit migrations enabled.
|
||||
- [ ] Upload/select the client-supplied Netzwerk video and the selected press image in production Payload.
|
||||
- [ ] Smoke-test all affected routes after deployment:
|
||||
- [ ] `/`
|
||||
- [ ] `/teilnahme`
|
||||
- [ ] `/der-bmp`
|
||||
- [ ] `/netzwerk`
|
||||
- [ ] `/presse`
|
||||
- [ ] representative event/article/winner detail routes
|
||||
- [ ] sitemap and robots endpoints
|
||||
- [ ] Perform a production unpublish/republish smoke test using disposable or approved test records.
|
||||
|
||||
## Feedback traceability
|
||||
|
||||
| Workbook feedback | Planned work |
|
||||
| --- | --- |
|
||||
| CMS unpublish button does not work | Tasks 1–3 and 18 |
|
||||
| `/teilnahme` steps 2–4 do not open; excess space | Task 7 |
|
||||
| Separate Ilse Aigner video from homepage | Task 5 |
|
||||
| Remove press-kit CMS controls | Task 4 |
|
||||
| Keep Pages focused and shared content in Globals | Task 4 |
|
||||
| Remove accreditation form copy | Task 4 |
|
||||
| Shared 2027-interest section should be dark blue | Task 13; current source already appears compliant |
|
||||
| `/der-bmp` relevance text/image alignment | Task 15 |
|
||||
| Partner tier hierarchy and responsive layout | Tasks 8–9 |
|
||||
| Add image beside press contact | Task 6 |
|
||||
| Add/fix sitemap in code | Task 17 |
|
||||
| Sponsor ticker feels too fast | Task 10 |
|
||||
| Homepage success heading line break | Task 11 |
|
||||
| Homepage bottom blue panel should be wider | Task 12 |
|
||||
| Participation eligibility area lacks structure | Task 14 |
|
||||
| `/der-bmp` facts and caption are misaligned/hard to read | Task 16 |
|
||||
| Confirm sponsor order | Task 9 |
|
||||
| “Aktuelles” looks good | No change required |
|
||||
@@ -11,6 +11,7 @@ import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
|
||||
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
|
||||
import { normalizeNewsletterFormData, type SpaNewsletterFormData } from '@/spa/newsletterForm'
|
||||
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
|
||||
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -43,8 +44,7 @@ async function findRouteDoc(payload: Awaited<ReturnType<typeof getPayload>>, pat
|
||||
const result = await payload.find({
|
||||
collection: target.collection,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
where: {
|
||||
@@ -63,8 +63,7 @@ async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collect
|
||||
const result = await payload.find({
|
||||
collection,
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 300,
|
||||
pagination: false,
|
||||
...(collection === 'partners' || collection === 'jury-mitglieder' ? { sort: 'sortOrder' } : {}),
|
||||
@@ -77,8 +76,7 @@ async function findSupportPages(payload: Awaited<ReturnType<typeof getPayload>>,
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1,
|
||||
draft,
|
||||
overrideAccess: draft,
|
||||
...getPublicationQueryOptions(draft),
|
||||
limit: 10,
|
||||
pagination: false,
|
||||
where: {
|
||||
|
||||
22
src/utilities/publicationQuery.ts
Normal file
22
src/utilities/publicationQuery.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type PublicationQueryOptions = Readonly<{
|
||||
draft: boolean
|
||||
overrideAccess: boolean
|
||||
}>
|
||||
|
||||
const publicPublicationQuery = Object.freeze({
|
||||
draft: false,
|
||||
overrideAccess: false,
|
||||
}) satisfies PublicationQueryOptions
|
||||
|
||||
const previewPublicationQuery = Object.freeze({
|
||||
draft: true,
|
||||
overrideAccess: true,
|
||||
}) satisfies PublicationQueryOptions
|
||||
|
||||
/**
|
||||
* Keeps public Local API reads access-controlled while allowing the authenticated
|
||||
* Next.js draft-mode route to intentionally load the latest draft versions.
|
||||
*/
|
||||
export function getPublicationQueryOptions(isPreview: boolean): PublicationQueryOptions {
|
||||
return isPreview ? previewPublicationQuery : publicPublicationQuery
|
||||
}
|
||||
241
tests/int/publication-contract.int.spec.ts
Normal file
241
tests/int/publication-contract.int.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import config from '@/payload.config'
|
||||
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
|
||||
import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const publicationCollections = [
|
||||
'pages',
|
||||
'preistraeger',
|
||||
'jury-mitglieder',
|
||||
'partners',
|
||||
'events',
|
||||
'posts',
|
||||
] as const
|
||||
|
||||
type PublicationCollection = (typeof publicationCollections)[number]
|
||||
|
||||
type PublicationCase<TCollection extends PublicationCollection = PublicationCollection> = {
|
||||
collection: TCollection
|
||||
data: (label: string) => RequiredDataFromCollectionSlug<TCollection>
|
||||
hasPublicDetailRoute: boolean
|
||||
}
|
||||
|
||||
const lexicalDocument = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal' as const,
|
||||
style: '',
|
||||
text: 'Publication contract fixture',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr' as const,
|
||||
format: '' as const,
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
let mediaID: number
|
||||
|
||||
const publicationCases: PublicationCase[] = [
|
||||
{
|
||||
collection: 'pages',
|
||||
data: (label) => ({
|
||||
layout: [{ blockType: 'content', columns: [] }],
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'preistraeger',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/preistraeger/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'jury-mitglieder',
|
||||
data: (label) => ({ _status: 'published', name: `Publication ${label}` }),
|
||||
hasPublicDetailRoute: false,
|
||||
},
|
||||
{
|
||||
collection: 'partners',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
logo: mediaID,
|
||||
name: `Publication ${label}`,
|
||||
stableId: `publication-${label}`,
|
||||
tier: 3,
|
||||
}),
|
||||
hasPublicDetailRoute: false,
|
||||
},
|
||||
{
|
||||
collection: 'events',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/presse/events/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
{
|
||||
collection: 'posts',
|
||||
data: (label) => ({
|
||||
_status: 'published',
|
||||
content: lexicalDocument,
|
||||
slug: `publication-${label}`,
|
||||
spaPath: `/presse/blog/publication-${label}`,
|
||||
title: `Publication ${label}`,
|
||||
}),
|
||||
hasPublicDetailRoute: true,
|
||||
},
|
||||
]
|
||||
|
||||
let payload: Payload
|
||||
|
||||
describe('draft-enabled collection publication contract', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config })
|
||||
|
||||
const imageData = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
)
|
||||
const media = await payload.create({
|
||||
collection: 'media',
|
||||
data: { alt: 'Publication contract fixture' },
|
||||
file: {
|
||||
data: imageData,
|
||||
mimetype: 'image/png',
|
||||
name: `publication-contract-${process.pid}.png`,
|
||||
size: imageData.length,
|
||||
},
|
||||
})
|
||||
mediaID = media.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (mediaID) {
|
||||
await payload.delete({ collection: 'media', id: mediaID })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps anonymous public and authenticated preview query modes explicit', () => {
|
||||
expect(getPublicationQueryOptions(false)).toEqual({
|
||||
draft: false,
|
||||
overrideAccess: false,
|
||||
})
|
||||
expect(getPublicationQueryOptions(true)).toEqual({
|
||||
draft: true,
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(publicationCases)(
|
||||
'returns only published $collection documents to anonymous public reads',
|
||||
async ({ collection, data, hasPublicDetailRoute }) => {
|
||||
const published = await payload.create({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
data: data(`${collection}-published`),
|
||||
draft: false,
|
||||
})
|
||||
const unpublished = await payload.create({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
data: data(`${collection}-unpublished`),
|
||||
draft: false,
|
||||
})
|
||||
|
||||
const storedDraft = await payload.update({
|
||||
collection,
|
||||
context: { disableRevalidate: true },
|
||||
id: unpublished.id,
|
||||
data: { _status: 'draft' },
|
||||
draft: false,
|
||||
})
|
||||
|
||||
expect(published._status).toBe('published')
|
||||
expect(storedDraft._status).toBe('draft')
|
||||
|
||||
const publicResult = await payload.find({
|
||||
collection,
|
||||
...getPublicationQueryOptions(false),
|
||||
pagination: false,
|
||||
where: {
|
||||
id: {
|
||||
in: [published.id, unpublished.id],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(publicResult.docs.map((doc) => doc.id)).toEqual([published.id])
|
||||
expect(publicResult.docs.every((doc) => doc._status === 'published')).toBe(true)
|
||||
|
||||
const previewResult = await payload.find({
|
||||
collection,
|
||||
...getPublicationQueryOptions(true),
|
||||
pagination: false,
|
||||
where: {
|
||||
id: {
|
||||
in: [published.id, unpublished.id],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(previewResult.docs.map((doc) => doc.id).sort()).toEqual(
|
||||
[published.id, unpublished.id].sort(),
|
||||
)
|
||||
|
||||
if (hasPublicDetailRoute) {
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: published.id,
|
||||
...getPublicationQueryOptions(false),
|
||||
}),
|
||||
).resolves.toMatchObject({ id: published.id, _status: 'published' })
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: unpublished.id,
|
||||
...getPublicationQueryOptions(false),
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
|
||||
await expect(
|
||||
payload.findByID({
|
||||
collection,
|
||||
id: unpublished.id,
|
||||
...getPublicationQueryOptions(true),
|
||||
}),
|
||||
).resolves.toMatchObject({ id: unpublished.id, _status: 'draft' })
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user