payloadcms/payload

Referenced documents can still be unpublished

Opened this issue · 5 comments

Describe the Bug

If one document is being referenced in a relationship field, we can prevent unpublished documents from being added to that field, by using a filter:

return {
          _status: {
            equals: 'published',
          },
        };

However, AFTER selecting the published document in the relationship field, that same document can still be unpublished. This causes a lot of issues, since published pages will remain pointing to unpublished documents. I see a few possible solutions, but I'm not sure which one would be the most viable:

  • Exposing a flag (i.e. weak: true or weak:false) that we could set when creating a relationship field that would throw an error by default if we tried to unpublish a document that is being referenced somewhere. IMHO, this is the ideal solution.
  • Using a hook to compare the previous _status with the new _status. However, this won't work if we have drafts and autosave enabled, since we are not able to determine if it's changing from published to _status because the user is unpublishing the document or just because they updated any content (since drafts and autosave are enabled).
  • Exposing an unpublish option under admin.components.edit that would allow us to implement this check before effectively unpublishing the document.
Image

Link to the code that reproduces this issue

https://github.com/lcnogueira/payload-draft-published-issue

Reproduction Steps

  • Install the packages: pnpm install.
  • Ensure you have the proper env variables under your .env file:
DATABASE_URI=mongodb://127.0.0.1/drafts-published-bug
PAYLOAD_SECRET=any-string-you-want
  • Use docker to start the database: docker-compose up.
  • Start the project: pnpm run dev.
  • Create an entry in the Places collection and publish it.
  • Create an entry in the Posts collection and select the place you just published in the Reference to Place Collection field:
Image
  • Open the published Places entry again and ensure you can unpublish it, even though it's being referenced by the Post entry.

Which area(s) are affected? (Select all that apply)

area: core

Environment Info

`
Binaries:
  Node: 23.11.1
  npm: 10.9.2
  Yarn: 1.22.19
  pnpm: 10.14.0
Relevant Packages:
  payload: 3.56.0
  next: 15.4.4
  @payloadcms/db-mongodb: 3.56.0
  @payloadcms/email-nodemailer: 3.56.0
  @payloadcms/graphql: 3.56.0
  @payloadcms/next/utilities: 3.56.0
  @payloadcms/payload-cloud: 3.56.0
  @payloadcms/richtext-lexical: 3.56.0
  @payloadcms/translations: 3.56.0
  @payloadcms/ui/shared: 3.56.0
  react: 19.1.0
  react-dom: 19.1.0
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 24.4.0: Fri Apr 11 18:33:39 PDT 2025; root:xnu-11417.101.15~117/RELEASE_ARM64_T6020
  Available memory (MB): 16384
  Available CPU cores: 10

@lcnogueira I ran into a similar issue a while back, so hopefully this helps.

In our case, we had a Global that referenced a collection used to showcase top news. The problem was that sometimes articles were unpublished, but their references in the Global weren’t automatically removed.

To fix this, we added an afterChange hook in the related collection. The hook checks if an article’s status changes from published to draft. If it does, it looks for any references to that article in the Global and removes them.

Here’s an example:

import type { CollectionAfterChangeHook } from 'payload'
import type { Article } from 'payload/generated-types'

export const removeTopNewsRelation: CollectionAfterChangeHook<Article> = async ({
  doc,
  previousDoc,
  req,
}) => {
  if (!previousDoc) return doc

  const wasPublished = previousDoc._status === 'published'
  const isNowDraft = doc._status === 'draft'

  if (!(wasPublished && isNowDraft)) return doc

  const articleID = doc.id

  const global = await req.payload.findGlobal({ slug: 'topNews' })
  const originalNews = global.news || []

  const updatedNewsArray =
    originalNews.filter(item => {
      const relatedID = typeof item.article === 'object' ? item.article.id : item.article
      return relatedID !== articleID
    }) || []

  const wesReferenced = originalNews.length !== updatedNewsArray.length
  if (!wasReferenced) return doc

  await req.payload.updateGlobal({
    slug: 'topNews',
    data: { news: updatedNewsArray },
  })

  return doc
}

Thanks so much for sharing, @marcmaceira!

Did your Global have drafts and autosave enabled? I'm asking because I think it's not possible to use that check with previousDoc._status and doc._status if you have those options enabled: let's say you have a published document and that collection has drafts and autosave enabled. As soon as the user types anything in the content, doc._status will become draft and previousDoc._status will be published. This means that it will match your check even though the document is not being unpublished.

That makes sense @lcnogueira. In our Articles collection we have a self-relation (one-to-many) to other Articles. We didn’t add an afterChange hook for this case. Instead, we use an afterRead hook to strip out any related articles that aren’t published when reading a published article. This ensures clients never receive unpublished items in the response.

We haven’t benchmarked this at large scale as our use case is small (typically 1–5 related articles). Here’s the collection's afterRead hook:

import type { CollectionAfterReadHook } from 'payload'
import type { Article } from 'payload/generated-types'

export const filterUnpublishedRelatedArticles: CollectionAfterReadHook<Article> = ({ doc }) => {
  // Check if the document and the relationship field exist and are populated - Here you would be checking for your document relationship field
  if (doc && doc.relatedArticles && Array.isArray(doc.relatedArticles)) {
    // Filter the relatedArticles array
    doc.relatedArticles = doc.relatedArticles.filter(related => {
      // If the relationship wasn't populated (depth = 0), 'related' will be an ID (string). Keep it.
      if (typeof related === 'string' || typeof related === 'number') {
        return false
      }

      // If populated (depth >= 1), 'related' is an object. Check its status.
      if (typeof related === 'object' && related !== null && related._status) {
        return related._status === 'published'
      }

      // Otherwise, filter it out
      return false
    })
  }

  // Return the modified document
  return doc
}

Thanks so much @marcmaceira ! Just confirming here for anyone else reading this that I tried this solution and it works, in the sense that we can remove the not published content once we read the data.

For our particular case, though, the solution won't be enough for the client. We'll need to find a solution to ensure the content won't be unpublished. @akhrarovsaid shared a solution in the payload discord channel that might work. I didn't have the time to focus on that yet, but I plan to try that next week and then share the results.

I am sharing here for visibility that the solution I mentioned in my previous comment, which was shared in the Discord payload, works. The solution would be replacing the publish button by using the admin.components.edit.PublishButton optoin to return both the regular PublishButton (exposed by Payload) AND the unpublish button, which is not exposed by payload, but can be copied from the project easily. Then, hiding the default unpublish button with CSS. By doing that, we can do any checks once the user clicks the unpublish button.