Skip to content

feat(task-board): a board whose columns are the org's own - #6698

Merged
viktormarinho merged 1 commit into
mainfrom
feat/dynamic-board-columns
Aug 28, 2026
Merged

feat(task-board): a board whose columns are the org's own#6698
viktormarinho merged 1 commit into
mainfrom
feat/dynamic-board-columns

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What is this contribution about?

Step three, and the one that makes the interface earn its keep: a second implementation.

dynamic_board_columns picks between them, in the single place a board is constructed — which is why #6682 and #6690 moved every caller to asking the board first. Nothing else in the codebase learns that boards can differ.

export function boardHandler(organizationId: string, deps: BoardHandlerDeps): BoardHandler {
  return deps.dynamicColumns
    ? new MirroredBoardHandler(...)
    : new StaticBoardHandler(...);
}

Only columns differs between them. Rules hang off a column key either way, so which board an org has has no bearing on what runs where — that is exactly what keying automations by column rather than by lane bought us in #6690, and it is why the second implementation is small.

The decisions I'd want challenged

A mirrored board with no columns renders empty, not Studio's lanes. Falling back would quietly re-introduce a vocabulary the org has said it does not use, and file its cards under lanes nobody chose. This is the single most load-bearing behaviour in the PR and it is asserted first in the tests.

The mode is an explicit flag, not inferred from whether rows exist. I used "the row's existence is the switch" for automations and it was right there — a missing rule is a safe default. Here it is not: a board converts before its first sync, so "dynamic but not yet populated" has to be a state you can be in. Inferring from rows would make that state indistinguishable from static and undo the point above.

replaceAll writes the set whole. The caller is mirroring a board it does not own, so a column deleted upstream has to disappear here — a merge would accumulate ghosts. But a surviving column keeps its role: that is Studio's reading of someone else's column, and the tracker has no idea it exists, so a re-mirror must not wipe it.

Still no table for the canonical board. Its columns remain a constant; the new table stays empty for every org that has not opted in.

How did you verify your code works?

Thirteen real-Postgres cases — the tier this needs, since the whole second implementation is rows and the properties that matter are about what is absent.

The static board's existing cases carry over unchanged (columns left to right, positions agreeing with LANE_RANK, an unconfigured board doing nothing anywhere, rules org-scoped). New for the mirrored one: renders empty before its columns arrive rather than Studio's lanes; renders the org's own columns in the given order (Backlog / Em Progresso / Code Review, i.e. names that are not ours); a mirrored column has no role until someone says what it means; a role survives a re-mirror while a vanished column is dropped — both halves in one assertion, since either alone would pass a broken implementation; and a rule hung on a tracker-named column fires while a canonical key does not.

1039 pass / 0 fail across apps/api/src/{tools/task-board,jira,storage,core}. bun run check 0 type errors, bun run lint 19 warnings identical to baseline, knip clean, fmt:check clean. Migration run against a real database.

Not verified, flagging honestly:

  • Nothing populates the rows. replaceAll and setRole have no caller outside tests — mirroring from Jira is the next step. I judged that better than shipping a half-mirror, but it does mean the mirrored board is unreachable in the product today: the flag exists, and turning it on gets you an empty board. That is a deliberately inert state, not a working feature.
  • No UI, for the mode or for roles. Same reasoning as feat(task-board): configure what a column runs, per column #6690's missing automation screen, and the debt is now two PRs deep.
  • No e2e. The board read goes through boardFor, so a mirrored org's TASK_BOARD_ITEM_LIST returns its own columns, but nothing asserts that over the wire.
  • boardFor adds a settings read per call. Three call sites today, one of them the board read. It is a single indexed row and almost certainly noise next to the queries beside it, but I did not measure it.

Screenshots/Demonstration

Not applicable — no user-visible change with the flag off, and the flag is off everywhere.

How to Test

  1. With the flag off, everything is as before: canonical columns, automations firing on canonical keys.
  2. Set dynamic_board_columns: true on a test org. The board read should return an empty columns array — not the nine lanes.
  3. Insert some columns via BoardColumnStorage.replaceAll and confirm they come back in order, with null roles.
  4. Set a role, re-run replaceAll dropping one column, and confirm the surviving column kept its role and the dropped one is gone.

Migration Notes

Migration 190 creates task_board_columns, empty. Reversible, and no existing row is touched. dynamic_board_columns is a new default-off org flag, so every org keeps the board it has.

Review Checklist

  • PR title is clear and descriptive
  • Changes are tested and working
  • Documentation is updated (if needed)
  • No breaking changes

Summary by cubic

Adds a second task-board implementation whose columns come from the org's own storage rather than Studio's canonical lanes. A default-off org_board_columns org flag picks between the two at the board's single construction point, so with the flag off nothing changes.

Behavior with the flag on

  • Columns come from the new task_board_columns table; a mirrored board with no columns yet renders empty, not Studio's lanes.
  • replaceAll writes the column set whole, so a column deleted upstream disappears while a surviving column keeps its role.
  • Rules hang off column keys on either board, so automations behave the same and no other code learns boards can differ.
  • status has no foreign key to task_board_columns because CASCADE, RESTRICT, and SET NULL are all wrong when a mirrored column disappears, so what its cards do stays app policy.
  • boardFor adds one settings read per call across the call sites.

Migration

  • Migration 191 creates the empty task_board_columns table; the canonical board's columns stay a constant and the table stays empty for every org that hasn't opted in.
  • Nothing populates the rows yet, so the mirrored board is deliberately inert in the product today; Jira mirroring is the next step.

Written for commit cd0473d. Summary will update on new commits.

Review in cubic

@viktormarinho
viktormarinho force-pushed the feat/dynamic-board-columns branch 2 times, most recently from aa871df to a1d2435 Compare August 28, 2026 17:09
Step three, and the one that makes the interface earn its keep: a second
implementation. `org_board_columns` picks between them, in the single place a
board is constructed, which is why every caller was moved to asking the board
first.

Named for who defines the column set, not for where it came from. Today the
only source is a Jira board, but mirroring is something the SYNC does — this
side only knows the columns are not ours to invent, so a column typed by hand
would land here too and nothing would need renaming.

An org board reads its columns from rows. Studio's own still answers from the
constant. Only `columns` differs — rules hang off a column key either way, so
whose board it is has no bearing on what runs where. That is what keying
automations by column rather than by lane bought.

An org board with no columns yet renders EMPTY, not Studio's lanes. Falling
back would quietly re-introduce a vocabulary the org has said it does not use,
and file its cards under lanes nobody chose. "Opted in but not yet populated"
has to be a state you can be in, which is also why the mode is an explicit flag
rather than inferred from whether rows exist.

`replaceAll` writes the set whole, because a column deleted upstream has to
disappear here. A column that survives keeps its `role`: that is Studio's
reading of someone else's column, and the tracker has no idea it exists.

No foreign key from `status`. Not because it cannot be conditional — a nullable
one can — but because every `ON DELETE` action is wrong for the case that
actually happens. A column removed upstream would either take the customer's
cards with it (CASCADE), block the sync (RESTRICT), or be impossible (SET NULL
on a NOT NULL column). What those cards should do is a policy, and policy
belongs in the application.

Nothing populates the rows yet — mirroring from Jira is the next step. What
lands here is the board that can hold them.
@viktormarinho
viktormarinho force-pushed the feat/dynamic-board-columns branch from a1d2435 to cd0473d Compare August 28, 2026 17:17
@viktormarinho
viktormarinho merged commit c61837e into main Aug 28, 2026
33 checks passed
@viktormarinho
viktormarinho deleted the feat/dynamic-board-columns branch August 28, 2026 17:25
decocms Bot pushed a commit that referenced this pull request Aug 28, 2026
PR: #6698 feat(task-board): a board whose columns are the org's own
Bump type: minor

- decocms (apps/api/package.json): 4.296.0 -> 4.297.0
- @decocms/native (apps/native/package.json): 4.296.0 -> 4.297.0
- @decocms/shared (packages/shared/package.json): 0.70.0 -> 0.71.0

Deploy-Scope: server
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant