[Bug]: getCards endpoint doesn't seem to respect filterUserIds or filterLabelIds #904

Closed
opened 2026-02-04 21:36:03 +03:00 by OVERLORD · 8 comments
Owner

Originally created by @hwelch-fle on GitHub (Jan 27, 2026).

Where is the problem occurring?

I encountered the problem while interacting with the server (Backend)

What browsers are you seeing the problem on?

Other

Current behavior

It seems that the options for filtering cards from a list are possibly not working correctly (nightly version). Here's some plankapy endpoint testing with the issues:

>>> len(lst.cards)
10 # 10
>>> len(lst.endpoints.getCards(lst.id, filterUserIds='')['items']) # Not clear if this should be only unassigned or all
10 # 0?
>>> len(lst.endpoints.getCards(lst.id, filterUserIds=planka.me.id)['items']) # current user only has one assigned card on board
10 # 1
>>> len(lst.endpoints.getCards(lst.id, search='Card 1')['items']) # Only one 'Card 1'
1 # 1 (this works)
>>> len(lst.endpoints.getCards(lst.id, filterLabelIds=board.labels[0].id)['items']) # One card has this label
10 # 1
>>> len(lst.endpoints.getCards(lst.id, before={'listChangedAt': '{oldest_card_timestamp}'})['items']) # `before` key also doesn't apply filter 
10 # 0
>>> len(lst.endpoints.getCards(lst.id, before={'id': 'last_card_id'})['items'])
10 # 0

This is using the raw endpoint to show that the issue seems to be with how the server is processing the request. I believe that the filterList and filterUser issues might be related to #1302 and #1487 since they're using that same regex for id validation. I'm not sure why the before option isn't working though. The validator seems to be written correctly as documented by the API:
2c4369159b/server/api/controllers/cards/index.js (L151-L165)
But it's not respecting the passed arguments. Could this also be failing with the id validators?
2c4369159b/server/utils/validators.js (L12-L13)

Desired behavior

see above

Steps to reproduce

see above

Other information

Here's the goal for plankapy syntax using this endpopint:

from plankapy.v2 import Planka
planka = Planka(URL)
planka.login(api_key=KEY)

prj = planka.projects[{'name': 'My Project'}].dpop()
board = prj.boards[{'name': 'My Board'}].dpop()
my_label = board.labels[{'name': 'Overdue'}].dpop()
last_card = board.cards.dpop()

len(board.filter(users=planka.me))
len(board.filter(labels=my_label))
len(board.filter(search='Card 1'))
len(board.filter(card_before=last_card))
len(board.filter(changed_before=last_card.list_changed_at))
Originally created by @hwelch-fle on GitHub (Jan 27, 2026). ### Where is the problem occurring? I encountered the problem while interacting with the server (Backend) ### What browsers are you seeing the problem on? Other ### Current behavior It seems that the options for filtering cards from a list are possibly not working correctly (`nightly` version). Here's some `plankapy` endpoint testing with the issues: ```python >>> len(lst.cards) 10 # 10 >>> len(lst.endpoints.getCards(lst.id, filterUserIds='')['items']) # Not clear if this should be only unassigned or all 10 # 0? >>> len(lst.endpoints.getCards(lst.id, filterUserIds=planka.me.id)['items']) # current user only has one assigned card on board 10 # 1 >>> len(lst.endpoints.getCards(lst.id, search='Card 1')['items']) # Only one 'Card 1' 1 # 1 (this works) >>> len(lst.endpoints.getCards(lst.id, filterLabelIds=board.labels[0].id)['items']) # One card has this label 10 # 1 >>> len(lst.endpoints.getCards(lst.id, before={'listChangedAt': '{oldest_card_timestamp}'})['items']) # `before` key also doesn't apply filter 10 # 0 >>> len(lst.endpoints.getCards(lst.id, before={'id': 'last_card_id'})['items']) 10 # 0 ``` This is using the raw endpoint to show that the issue seems to be with how the server is processing the request. I believe that the `filterList` and `filterUser` issues might be related to #1302 and #1487 since they're using that same regex for id validation. I'm not sure why the `before` option isn't working though. The validator seems to be written correctly as documented by the API: https://github.com/plankanban/planka/blob/2c4369159bd42a16b8e66a54b987781b4c6d244d/server/api/controllers/cards/index.js#L151-L165 But it's not respecting the passed arguments. Could this also be failing with the `id` validators? https://github.com/plankanban/planka/blob/2c4369159bd42a16b8e66a54b987781b4c6d244d/server/utils/validators.js#L12-L13 ### Desired behavior see above ### Steps to reproduce see above ### Other information Here's the goal for plankapy syntax using this endpopint: ```python from plankapy.v2 import Planka planka = Planka(URL) planka.login(api_key=KEY) prj = planka.projects[{'name': 'My Project'}].dpop() board = prj.boards[{'name': 'My Board'}].dpop() my_label = board.labels[{'name': 'Overdue'}].dpop() last_card = board.cards.dpop() len(board.filter(users=planka.me)) len(board.filter(labels=my_label)) len(board.filter(search='Card 1')) len(board.filter(card_before=last_card)) len(board.filter(changed_before=last_card.list_changed_at)) ```
Author
Owner

@meltyshev commented on GitHub (Jan 28, 2026):

Hey! Thanks for reporting this.

We renamed these parameters to just userIds and labelIds to keep naming consistent across the project. That's probably why they're being ignored in the nightly version... If not, it'd be interesting to investigate further, but from my testing in PLANKA (since it uses the same API) - the endpoint itself returns the correct data for me.

And regarding the before parameter, I think I ran into the same issue when testing it via Postman. The original idea of sending a JSON structure as a single parameter doesn't seem to work - it's only decoded correctly when PLANKA sends it via websocket while emulating an HTTP request. I'll test further and see what can be done here.

@meltyshev commented on GitHub (Jan 28, 2026): Hey! Thanks for reporting this. We renamed these parameters to just `userIds` and `labelIds` to keep naming consistent across the project. That's probably why they're being ignored in the `nightly` version... If not, it'd be interesting to investigate further, but from my testing in PLANKA (since it uses the same API) - the endpoint itself returns the correct data for me. And regarding the `before` parameter, I think I ran into the same issue when testing it via Postman. The original idea of sending a JSON structure as a single parameter doesn't seem to work - it's only decoded correctly when PLANKA sends it via websocket while emulating an HTTP request. I'll test further and see what can be done here.
Author
Owner

@hwelch-fle commented on GitHub (Jan 28, 2026):

We renamed these parameters to just userIds and labelIds to keep naming consistent across the project. That's probably why they're being ignored in the nightly version... If not, it'd be interesting to investigate further, but from my testing in PLANKA (since it uses the same API) - the endpoint itself returns the correct data for me.

Really odd... I did try testing again with those keys and I'm still getting the same issue of everything being returned. No server errors or anything, it's just not applying the filter...

And regarding the before parameter, I think I ran into the same issue when testing it via Postman. The original idea of sending a JSON structure as a single parameter doesn't seem to work

Hmmm... I think this same issue is causing stopwatches to fail out too. I'm formatting the request properly, but it's rejecting anything I give it. I tried a string, JSON, and raw bytes using multipart-form encoding.

I'll keep playing around with it and see if its something stupid I'm doing (likely), and once I exhaust that bottomless well of possibility I'll try and run through what's happening server side and see if it's some improperly formatted handler or something.

@hwelch-fle commented on GitHub (Jan 28, 2026): > We renamed these parameters to just `userIds` and` labelIds` to keep naming consistent across the project. That's probably why they're being ignored in the nightly version... If not, it'd be interesting to investigate further, but from my testing in PLANKA (since it uses the same API) - the endpoint itself returns the correct data for me. Really odd... I did try testing again with those keys and I'm still getting the same issue of everything being returned. No server errors or anything, it's just not applying the filter... >And regarding the before parameter, I think I ran into the same issue when testing it via Postman. The original idea of sending a JSON structure as a single parameter doesn't seem to work Hmmm... I think this same issue is causing stopwatches to fail out too. I'm formatting the request properly, but it's rejecting anything I give it. I tried a string, JSON, and raw bytes using multipart-form encoding. I'll keep playing around with it and see if its something stupid I'm doing (likely), and once I exhaust that bottomless well of possibility I'll try and run through what's happening server side and see if it's some improperly formatted handler or something.
Author
Owner

@hwelch-fle commented on GitHub (Jan 28, 2026):

@meltyshev It was indeed a footgun. I build the path schema off your openapi/swagger doc and that was out of date. All other endpoints allow for arbitrary keys, but parameter endpoints enforce parameterization for GET requests to keep them out of the body. So the keys were being dropped -_-

    def getCards(self, listId: str, **kwargs: Unpack[Request_getCards]) -> Response_getCards:
        """Retrieves cards from an endless list with filtering, search, and pagination support.

        Args:
            listId (str): ID of the list to get cards from (must be an endless list))
            before (str): Pagination cursor (JSON object with id and listChangedAt)) (optional)
            search (str): Search term to filter cards) (optional)
            -- filterUserIds (str): Comma-separated user IDs to filter by members) (optional)
            ++ userIds (str): Comma-separated user IDs to filter by members) (optional)
            -- filterLabelIds (str): Comma-separated label IDs to filter by labels) (optional)
            ++ labelIds (str): Comma-separated label IDs to filter by labels) (optional)

        Note:
            All status errors are instances of `httpx.HTTPStatusError` at runtime (`response.raise_for_status()`). 
            If a matching PlankaError exists, it will be raised (see `api.errors`) 
            Planka internal status codes and names are included here for disambiguation

        Raises:
            ValidationError: 400 
            Unauthorized: 401 
            NotFound: 404 
        """
        -- valid_params = ('before', 'search', 'filterUserIds', 'filterLabelIds')        
        ++ valid_params = ('before', 'search', 'userIds', 'labelIds')
        passed_params = {k: v for k, v in kwargs.items() if k in valid_params if isinstance(v, str | int | float)}
        resp = self.client.get(f"api/lists/{listId}/cards", params=passed_params)
        raise_planka_err(resp)
        return resp.json()

It was the second change that was causing the silent failure. That pattern is enforced to prevent parameter args from being passed in the body.

@hwelch-fle commented on GitHub (Jan 28, 2026): @meltyshev It was indeed a footgun. I build the path schema off your openapi/swagger doc and that was out of date. All other endpoints allow for arbitrary keys, but parameter endpoints enforce parameterization for `GET` requests to keep them out of the body. So the keys were being dropped -_- ```python def getCards(self, listId: str, **kwargs: Unpack[Request_getCards]) -> Response_getCards: """Retrieves cards from an endless list with filtering, search, and pagination support. Args: listId (str): ID of the list to get cards from (must be an endless list)) before (str): Pagination cursor (JSON object with id and listChangedAt)) (optional) search (str): Search term to filter cards) (optional) -- filterUserIds (str): Comma-separated user IDs to filter by members) (optional) ++ userIds (str): Comma-separated user IDs to filter by members) (optional) -- filterLabelIds (str): Comma-separated label IDs to filter by labels) (optional) ++ labelIds (str): Comma-separated label IDs to filter by labels) (optional) Note: All status errors are instances of `httpx.HTTPStatusError` at runtime (`response.raise_for_status()`). If a matching PlankaError exists, it will be raised (see `api.errors`) Planka internal status codes and names are included here for disambiguation Raises: ValidationError: 400 Unauthorized: 401 NotFound: 404 """ -- valid_params = ('before', 'search', 'filterUserIds', 'filterLabelIds') ++ valid_params = ('before', 'search', 'userIds', 'labelIds') passed_params = {k: v for k, v in kwargs.items() if k in valid_params if isinstance(v, str | int | float)} resp = self.client.get(f"api/lists/{listId}/cards", params=passed_params) raise_planka_err(resp) return resp.json() ``` It was the second change that was causing the silent failure. That pattern is enforced to prevent parameter args from being passed in the body.
Author
Owner

@meltyshev commented on GitHub (Jan 28, 2026):

Oops, sorry about the name changes 😄 but no more changes are expected, so this should be the last issue related to that.

I've also just tested how to pass the before parameter via GET and figured out that it needs to use a nested structure like this: ?before[listChangedAt]=2024-01-01T00:00:00.000Z&before[id]=1357158568008091269 (I assume it should be handled automatically by httpx?).

When trying to pass it as JSON like ?before={"listChangedAt":"2024-01-01T00:00:00.000Z","id":"1357158568008091269"} - it gets parsed as a string and fails validation.

@meltyshev commented on GitHub (Jan 28, 2026): Oops, sorry about the name changes 😄 but no more changes are expected, so this should be the last issue related to that. I've also just tested how to pass the `before` parameter via GET and figured out that it needs to use a nested structure like this: `?before[listChangedAt]=2024-01-01T00:00:00.000Z&before[id]=1357158568008091269` (I assume it should be handled automatically by `httpx`?). When trying to pass it as JSON like `?before={"listChangedAt":"2024-01-01T00:00:00.000Z","id":"1357158568008091269"}` - it gets parsed as a string and fails validation.
Author
Owner

@meltyshev commented on GitHub (Jan 28, 2026):

And we probably need to update the definition of the GET parameters in Swagger.

Instead of:

- name: before
  in: query
  required: false
  description: Pagination cursor (JSON object with listChangedAt and id)
  schema:
    type: string
    example: '{"listChangedAt": "2024-01-01T00:00:00.000Z", "id": "1357158568008091269"}'

It should be something like:

- name: before
  in: query
  required: false
  style: deepObject
  explode: true
  description: Pagination cursor
  schema:
    type: object
    properties:
      listChangedAt:
        type: string
        format: date-time
        example: "2024-01-01T00:00:00.000Z"
      id:
        type: string
        example: "1357158568008091269"

UPD: not sure why, but https://editor.swagger.io shows an error when using style: deepObject, so it's probably better to define these parameters explicitly like this:

- name: before[listChangedAt]
  in: query
  required: false
  description: Pagination cursor field `listChangedAt` (use together with `before[id]`)
  schema:
    type: string
    format: date-time
    example: 2024-01-01T00:00:00.000Z
- name: before[id]
  in: query
  required: false
  description: Pagination cursor field `id` (use together with `before[listChangedAt]`)
  schema:
    type: string
    example: "1357158568008091269"
@meltyshev commented on GitHub (Jan 28, 2026): And we probably need to update the definition of the GET parameters in Swagger. Instead of: ``` - name: before in: query required: false description: Pagination cursor (JSON object with listChangedAt and id) schema: type: string example: '{"listChangedAt": "2024-01-01T00:00:00.000Z", "id": "1357158568008091269"}' ``` It should be something like: ``` - name: before in: query required: false style: deepObject explode: true description: Pagination cursor schema: type: object properties: listChangedAt: type: string format: date-time example: "2024-01-01T00:00:00.000Z" id: type: string example: "1357158568008091269" ``` UPD: not sure why, but https://editor.swagger.io shows an error when using `style: deepObject`, so it's probably better to define these parameters explicitly like this: ``` - name: before[listChangedAt] in: query required: false description: Pagination cursor field `listChangedAt` (use together with `before[id]`) schema: type: string format: date-time example: 2024-01-01T00:00:00.000Z - name: before[id] in: query required: false description: Pagination cursor field `id` (use together with `before[listChangedAt]`) schema: type: string example: "1357158568008091269" ```
Author
Owner

@hwelch-fle commented on GitHub (Jan 28, 2026):

Is there a reason it needs to be passed as a deepObject? Why not just make it two parameters beforeListChangedAt and beforeCardId.

Just seems off that this would be the only instance of that style of parameter in the whole API.

Plus that syntax for name means it becomes an invalid property name in Python and means I need to add another edge case to the schema parser that uses the functional style object declaration 😅

@hwelch-fle commented on GitHub (Jan 28, 2026): Is there a reason it needs to be passed as a deepObject? Why not just make it two parameters `beforeListChangedAt` and `beforeCardId`. Just seems off that this would be the only instance of that style of parameter in the whole API. Plus that syntax for name means it becomes an invalid property name in Python and means I need to add another edge case to the schema parser that uses the functional style object declaration 😅
Author
Owner

@meltyshev commented on GitHub (Jan 28, 2026):

Yep, there's a reason for that. With two separate parameters it's hard to understand that they actually work together as a single cursor. If you just see two different parameter names, it's not obvious that the results are first ordered by listChangedAt, and then (only if multiple items have the same listChangedAt) the id field is used to continue the range.

This was added when we implemented bulk archiving cards from the Closed list - all cards (and there can be 100+ of them) can be moved to the Archive list with the same listChangedAt timestamp. To load 50 cards and then the next 50, we need to pass both listChangedAt and the last card id together, otherwise (if we pass only listChangedAt) we would either load the same cards again or skip the next batch.

if (before) {
  queryValues.push(before.listChangedAt);
  query += ` AND (card.list_changed_at < $${queryValues.length} OR (card.list_changed_at = $${queryValues.length}`;

  queryValues.push(before.id);
  query += ` AND card.id < $${queryValues.length}))`;
}

We use the same approach in the Pro version as well. For example, when lazily loading users (where fetching and filtering is done server-side to handle thousands of users) - we first sort by the name field, and since multiple users can share the same name, then use id as a secondary field to keep pagination consistent.

@meltyshev commented on GitHub (Jan 28, 2026): Yep, there's a reason for that. With two separate parameters it's hard to understand that they actually work together as a single cursor. If you just see two different parameter names, it's not obvious that the results are first ordered by `listChangedAt`, and then (only if multiple items have the same `listChangedAt`) the `id` field is used to continue the range. This was added when we implemented bulk archiving cards from the Closed list - all cards (and there can be 100+ of them) can be moved to the Archive list with the same `listChangedAt` timestamp. To load 50 cards and then the next 50, we need to pass both `listChangedAt` and the last card `id` together, otherwise (if we pass only `listChangedAt`) we would either load the same cards again or skip the next batch. ``` if (before) { queryValues.push(before.listChangedAt); query += ` AND (card.list_changed_at < $${queryValues.length} OR (card.list_changed_at = $${queryValues.length}`; queryValues.push(before.id); query += ` AND card.id < $${queryValues.length}))`; } ``` We use the same approach in the Pro version as well. For example, when lazily loading users (where fetching and filtering is done server-side to handle thousands of users) - we first sort by the `name` field, and since multiple users can share the same name, then use `id` as a secondary field to keep pagination consistent.
Author
Owner

@hwelch-fle commented on GitHub (Jan 28, 2026):

To load 50 cards and then the next 50, we need to pass both listChangedAt and the last card id together, otherwise (if we pass only listChangedAt) we would either load the same cards again or skip the next batch.

This makes a lot more sense now. I was under the impression that before was meant to get cards that were moved before a date or are before a specific card in a list.

I might end up just not exposing this in the public API since it seems to be more for internal management of lists. It would be easy enough to just grab all board cards from board.included and build something more akin to activity filtering that way.

@hwelch-fle commented on GitHub (Jan 28, 2026): > To load 50 cards and then the next 50, we need to pass both listChangedAt and the last card id together, otherwise (if we pass only listChangedAt) we would either load the same cards again or skip the next batch. This makes a lot more sense now. I was under the impression that `before` was meant to get cards that were moved before a date *or* are before a specific card in a list. I might end up just not exposing this in the public API since it seems to be more for internal management of lists. It would be easy enough to just grab all board cards from `board.included` and build something more akin to activity filtering that way.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/planka#904