Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Filtering documents returned from localAPI using beforeRead hook.

default discord avatar
steadysnaillast year
22

I'm implementing 'soft deletion' for the 'licenses' collection in the Customer Management Application we are currently building out.



Without going too deep into the nitty gritty, our software running on the client's server will send an EDA message to our Customer Management App when one of there licenses has been removed/deleted. When this occurs the corresponding License record's 'deleted' property (boolean/checkbox) is updated to true (which then makes it eligible for a manual hard deletion by an admin).



When a license is marked 'deleted' (but a hard manual deletion has not occurred), we would like to show the record on the admin panel as normal (i.e. when req.user._strategy === "local-jwt" ), but we do not want to return the record with any localAPI queries.



Because we are using the localAPI to query the licenses in many different places in our codebase, and because we will likely be implementing the same "soft deletion" on several other collections in the future, it would be preferable to implement a reusable strategy and not have to update every payload.find's 'where' statement.



My original thought was to use a collection beforeRead hook to accomplish this, however, I have not been able to get the hook to not return the document.



Is there a way to accomplish this using a beforeRead hook? If not, what would the recommended approach be?



Below is the beforeRead hooks I attempted to implement.



Thanks!



const removeDeletedFromResponse: CollectionBeforeReadHook = async ({
  doc,
  req,
  query,
}) => {
    if (req.user && req.user._strategy === "local-jwt" || !doc.deleted) {
      return doc;
    }

    // ALSO ATTEMPTED:

    // else {
    //  return null;
    // }
    
    // else {
    //     if ("and" in query) {
    //         query.and.push({ deleted: { equals: false } });
    //     } else {
    //         query.and = [{ deleted: { equals: false } }];
    //     }
    // }
};
  • default discord avatar
    notchrlast year
    @919477830927130655

    What was the issue with beforeReaD?



    Did this condition fail?

    if (req.user && req.user._strategy === "local-jwt" || !doc.deleted) { return doc; }
  • default discord avatar
    steadysnaillast year
    @1049775120559898725

    I was not able to get the beforeRead hook to NOT return the doc. The doc is returned regardless of whether or not the conditional was met.

  • default discord avatar
    notchrlast year

    Hmm



    Even when you returned null?



    @919477830927130655

    I think im confused about the following



    You want an action to occur once a doc is updated



    Why is there an issue with beforeRead returning doc?



    What about field level access control?



    For example



    https://payloadcms.com/docs/access-control/fields#read

    Fields access control "read" have access to the doc in its entirety



    And the user



    Collection access control provides the user and document id



    (for read)



    You could, in your collection, define an access control function



    and then put that on each field you want to hide



    Otherwise



    Just block requests to the default endpoints of the collection



    And author custom endpoints on the collection



    Which receive the same data as the normal endpoints by default



    https://payloadcms.com/docs/rest-api/overview#custom-endpoints

    Hmm, though that may break Admin display



    So i think access control is cleaner

  • default discord avatar
    steadysnaillast year

    Yes the doc was still returned, even when I returned null from the beforeRead hook.



    No, I don't want an action to occur once a doc is updated. I'm looking for an easy way to exclude license records marked 'deleted' from all localApi find queries that are not performed by a user with a user._strategy equal to "local-jwt." So, if a user goes to the 'licenses' collection on the admin panel they should still be able to see license records that have been marked 'deleted'. But if a hook (for example) queries the licenses collection, licenses marked 'deleted' should not be returned.



    I have 'read' field access controls implemented in some places already, but doesn't that only limit the ability to read a particular field? I want to exclude the entire document/record not just a field.

  • default discord avatar
    notchrlast year

    i see



    ok give me a min

  • default discord avatar
    steadysnaillast year

    I know I could accomplish my goal by adding something like the following to every localApi find's where statement, but I was hoping to not have to add the same lines to dozens (potentially hundreds if we implement this on other collections) of places:


    or: [
     {
                  deleted: {
                    equals: false,
                  },
                },
                {
                  deleted: {
                    exists: false,
                  },
                },
    ]
  • default discord avatar
    notchrlast year

    True



    I'm booting up my sandbox proj



    I have a couple of ideas

  • default discord avatar
    steadysnaillast year

    If the collection Read access control had access to the the doc like it does in update/create it would be ideal.

  • default discord avatar
    notchrlast year

    It has the id of the doc

  • default discord avatar
    steadysnaillast year

    Thanks so much by the way, I really appreciate you taking the time to lend a hand.



    True, I could query the doc to find the deleted property if !req.user || req.user._strategy !== "local-jwt".



    I'm going to give that a try, but wouldn't that essentially cause every doc to be queried twice if the above conditional is true?

  • default discord avatar
    notchrlast year

    Ok



    I think I have an ida



    @919477830927130655

    idea*



    It's not done yet but



    Let me hear your thoughts



    const afterReadHook: CollectionAfterReadHook = async ({
      doc, // full document data
      req, // full express request
      query, // JSON formatted query
      findMany, // boolean to denote if this hook is running against finding one, or finding many
    }) => {
      console.log(req.originalUrl);
      return { ...doc, foo: 'bar' };
    };
    
    const Test: CollectionConfig = {
      slug: 'test',
      access: {
        read: () => true,
      },
      fields: [
        {
          name: 'title',
          label: 'Title',
          type: 'text',
          required: true,
        },
      ],
      hooks: {
        afterRead: [afterReadHook],
      },
    };


    This afterReadHook provides the doc



    (and the req info)



    I was able to manipulate what was returned from the API call via that spreaded property



    So my idea is



    Check the req url, or some info on the request to confirm it came from the Payload page



    and return the document



    otherwise, return an empty object



    req.originalUrl may not be the play here, that, for example, returns

    /payload/api/test/663e1eec7d50e7c537f23155?depth=0&draft=true&fallback-locale=null

    So you could check if the doc ID (which i think is hidden?) exists in the request url



    But that's a little hacky, there must be a better way to identify the request origin



    I think though, this is getting close to a solution



    @919477830927130655

    yeah no prob man, the community is cool and if i cant help you, im sure one of the payload staff will

  • default discord avatar
    steadysnaillast year

    I'm going to play around with this a bit. I think this might be the hook I want to use. I was thinking afterRead ran AFTER the docs were returned. I'll report back if I find the solution. Much Appreciated!

  • default discord avatar
    notchrlast year

    Of course 😄

  • default discord avatar
    ritsu0455last year

    have you considered

    beforeOperation

    , as you can modify the args with it before any code in the operation will be executed? in this case you want to append the query to

    args.where

    if it's

    find

    operation.



    i haven't read too much here so might have a wrong idea, but that's what i was using for cases like these

  • default discord avatar
    steadysnaillast year
    @1049775120559898725

    It looks like I'm having more or less the same issue with the afterRead hook. If I return null or undefined the doc is still included in the "docs" returned to the localAPI call. If I return an empty object, the empty object is returned in the "docs." I could definitely make it work returning an empty object but I would still need to update all my localAPI calls to handle/ignore empty objects which would negate the purpose of doing this and probably be just as much work /code repetition as just updating the 'where' statements of the localAPI calls.

  • default discord avatar
    notchrlast year

    Whats the issue of the localApi returning the doc?

  • default discord avatar
    steadysnaillast year
    @423216344302092288

    I'll give it a try. Thanks.



    @1049775120559898725


    Lets say I have 2 "licenses":


    License A (not marked deleted)


    License B (marked deleted)



    What I would want to be returned from the local API find call is:


    [{License A}]



    If I return null/undefined from the hook the "docs" in the response from the localAPI find call looks like:


    [{License A}, {License B}]



    If I return an empty object for licenses marked deleted I get:


    [{License A}, {}]

  • default discord avatar
    notchrlast year

    Cant you return the document with the field spreaded off?



    The idea was to only return empty if the origin was from a REST call not in the admin panel



    hmm

  • default discord avatar
    steadysnaillast year

    Yeah I can but, I don't want to include the document at all.



    @1049775120559898725

    @423216344302092288

    /solve The

    beforeOperation

    hook is the answer. Here's what I implemented, this will only add the where: {deleted: {equals: false}} for "reads" not initiated by a user and where there isn't already a deleted property in the where clause. This way if I need to query docs marked deleted I still can (as long as I explicitly include it in the original where statement). I'm going to expand this to check for a 'deleted' property at all levels of the where clause.



    import { CollectionBeforeOperationHook } from "payload/types";
    
    const removeDeletedFromResponse: CollectionBeforeOperationHook = async ({
      args,
      operation,
      req,
    }) => {
      if (operation === "read") {
        if (
          (!req.user || req.user._strategy !== "local-jwt") &&
          !("deleted" in args.where)
        ) {
          args.where.deleted = {
            equals: false,
          };
        }
      }
      return args;
    };


    Thanks again

    @1049775120559898725

    and

    @423216344302092288

    yall's help is much appreciated!

  • default discord avatar
    notchrlast year

    Yooo nicely done!

  • default discord avatar
    ritsu0455last year

    Nice to see it works,

    beforeOperation

    is actually a powerful thing but sometimes people forget about its existence



    but i would improve your solution with adding

    and

    to the

    where

    instead of just

    args.where.deleted

    because you can already have

    where

    in the arg, and you want here to

    append

    the query with

    deleted

    , not overwrite



    like this

    and: [originalWhere, { deleted: {...} }]

    but up to you ofc



    here's how payload does it, i believe you can even import it from payload/database

    https://github.com/payloadcms/payload/blob/main/packages/payload/src/database/combineQueries.ts
  • default discord avatar
    steadysnaillast year
    @423216344302092288

    I updated the codeblock in my previous comment to recursively look for a "deleted" key in the where clause at all levels, I'm only adding the

    where.deleted = {equals: false}

    if there is not already a deleted key in the where clause. Maybe I'm mistaken but I don't think this would override the where clause, just the deleted property (which it won't because I'm checking if it exists first).



    Either way, combining the originalWhere and new 'deleted' property into an 'and' array doesn't seem like a bad idea, I'm going to implement that.

Star on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get dedicated engineering support directly from the Payload team.