|
| 1 | +# Admin Edit of Closed-Meeting Action Items — Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Let admins update a closed meeting's action items (markdown edit *and* checkbox toggles), while non-admins stay read-only on closed meetings — and fix the latent bug that makes task-list checkboxes non-interactive for everyone. |
| 6 | + |
| 7 | +**Architecture:** Introduce one shared pure predicate `canToggleActionItems(status, isAdmin) = isAdmin || status === 'open'` in `src/lib/action-items.ts`, unit-tested in the existing logic-only Vitest suite. The server action `toggleActionItem` and the client `ActionItemsPanel` both consume it (DRY). `MarkdownView` gains an opt-in `interactiveCheckboxes` prop that renders task-list checkboxes without `disabled` (uncontrolled `defaultChecked`) so clicks actually fire. No DB/RLS migration — the `meetings_update_admin` RLS policy already permits admin updates of closed meetings. |
| 8 | + |
| 9 | +**Tech Stack:** Next.js 16 App Router, React 19, TypeScript strict, Vitest, react-markdown v10 + remark-gfm v4, Supabase. Spec: `docs/superpowers/specs/2026-05-30-admin-edit-closed-meeting-action-items-design.md`. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +### Task 1: Add the `canToggleActionItems` predicate (TDD) |
| 14 | + |
| 15 | +**Files:** |
| 16 | +- Modify: `src/lib/action-items.ts` |
| 17 | +- Test: `src/lib/action-items.test.ts` |
| 18 | + |
| 19 | +- [ ] **Step 1: Write the failing test** |
| 20 | + |
| 21 | +Append this block to the end of `src/lib/action-items.test.ts`: |
| 22 | + |
| 23 | +```ts |
| 24 | +describe('canToggleActionItems', () => { |
| 25 | + it('allows any user on an open meeting', () => { |
| 26 | + expect(canToggleActionItems('open', false)).toBe(true) |
| 27 | + expect(canToggleActionItems('open', true)).toBe(true) |
| 28 | + }) |
| 29 | + |
| 30 | + it('allows admins on a closed meeting', () => { |
| 31 | + expect(canToggleActionItems('closed', true)).toBe(true) |
| 32 | + }) |
| 33 | + |
| 34 | + it('blocks non-admins on a closed meeting', () => { |
| 35 | + expect(canToggleActionItems('closed', false)).toBe(false) |
| 36 | + }) |
| 37 | +}) |
| 38 | +``` |
| 39 | + |
| 40 | +Also update the import at the top of the test file to include the new symbol: |
| 41 | + |
| 42 | +```ts |
| 43 | +import { |
| 44 | + canToggleActionItems, |
| 45 | + countActionItems, |
| 46 | + extractMentions, |
| 47 | + toggleCheckboxAt, |
| 48 | +} from './action-items' |
| 49 | +``` |
| 50 | + |
| 51 | +- [ ] **Step 2: Run the test to verify it fails** |
| 52 | + |
| 53 | +Run: `npx vitest run src/lib/action-items.test.ts` |
| 54 | +Expected: FAIL — `canToggleActionItems is not a function` / not exported. |
| 55 | + |
| 56 | +- [ ] **Step 3: Write the minimal implementation** |
| 57 | + |
| 58 | +Add to `src/lib/action-items.ts` (after the `toggleCheckboxAt` function, before `countActionItems`): |
| 59 | + |
| 60 | +```ts |
| 61 | +/** |
| 62 | + * Who may toggle / edit a meeting's action items. |
| 63 | + * Any authenticated user may toggle while the meeting is open; admins may |
| 64 | + * always edit, including after the meeting is closed. Non-admins are |
| 65 | + * read-only on closed meetings. Shared by the server action and the panel UI. |
| 66 | + */ |
| 67 | +export function canToggleActionItems( |
| 68 | + status: string, |
| 69 | + isAdmin: boolean, |
| 70 | +): boolean { |
| 71 | + return isAdmin || status === 'open' |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +- [ ] **Step 4: Run the test to verify it passes** |
| 76 | + |
| 77 | +Run: `npx vitest run src/lib/action-items.test.ts` |
| 78 | +Expected: PASS (all `canToggleActionItems` cases green, existing tests still green). |
| 79 | + |
| 80 | +- [ ] **Step 5: Commit** |
| 81 | + |
| 82 | +```bash |
| 83 | +git add src/lib/action-items.ts src/lib/action-items.test.ts |
| 84 | +git commit -m "feat: add canToggleActionItems predicate |
| 85 | +
|
| 86 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" |
| 87 | +``` |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +### Task 2: Allow admins to toggle on closed meetings (server action) |
| 92 | + |
| 93 | +**Files:** |
| 94 | +- Modify: `src/lib/actions/meetings.ts` (import line 20; `toggleActionItem` ~line 351) |
| 95 | + |
| 96 | +- [ ] **Step 1: Extend the import** |
| 97 | + |
| 98 | +Change line 20 from: |
| 99 | + |
| 100 | +```ts |
| 101 | +import { toggleCheckboxAt } from '@/lib/action-items' |
| 102 | +``` |
| 103 | + |
| 104 | +to: |
| 105 | + |
| 106 | +```ts |
| 107 | +import { canToggleActionItems, toggleCheckboxAt } from '@/lib/action-items' |
| 108 | +``` |
| 109 | + |
| 110 | +- [ ] **Step 2: Replace the status gate** |
| 111 | + |
| 112 | +In `toggleActionItem`, replace this line (~351): |
| 113 | + |
| 114 | +```ts |
| 115 | + if (m.status !== 'open') return actionError('This meeting is closed') |
| 116 | +``` |
| 117 | + |
| 118 | +with: |
| 119 | + |
| 120 | +```ts |
| 121 | + const isAdmin = user.profile?.role === 'admin' |
| 122 | + if (!canToggleActionItems(m.status, isAdmin)) { |
| 123 | + return actionError('This meeting is closed') |
| 124 | + } |
| 125 | +``` |
| 126 | + |
| 127 | +(`user` is already in scope from `const user = await getCurrentUser()` at the top of the action; `m.status` comes from the existing `.select('status, action_items_md')` query.) |
| 128 | + |
| 129 | +- [ ] **Step 3: Verify it type-checks and tests pass** |
| 130 | + |
| 131 | +Run: `npx tsc --noEmit && npx vitest run src/lib/action-items.test.ts` |
| 132 | +Expected: tsc exits 0; tests PASS. |
| 133 | + |
| 134 | +- [ ] **Step 4: Commit** |
| 135 | + |
| 136 | +```bash |
| 137 | +git add src/lib/actions/meetings.ts |
| 138 | +git commit -m "feat: let admins toggle action items on closed meetings |
| 139 | +
|
| 140 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" |
| 141 | +``` |
| 142 | + |
| 143 | +--- |
| 144 | + |
| 145 | +### Task 3: Add `interactiveCheckboxes` to `MarkdownView` |
| 146 | + |
| 147 | +**Files:** |
| 148 | +- Modify: `src/components/markdown-view.tsx` |
| 149 | + |
| 150 | +Note: there is no DOM test harness in this repo (no `@testing-library/react` / jsdom), so this task is verified by `npm run build` + a manual check in Task 5, not a unit test. Keep the change minimal and the default behavior (`interactiveCheckboxes` omitted) byte-for-byte identical to today. |
| 151 | + |
| 152 | +- [ ] **Step 1: Add the prop to the `Props` type** |
| 153 | + |
| 154 | +In `src/components/markdown-view.tsx`, add to the `Props` type (after `mentions`): |
| 155 | + |
| 156 | +```ts |
| 157 | + /** |
| 158 | + * When true, render GFM task-list checkboxes as interactive (no `disabled` |
| 159 | + * attribute) so clicks fire. Uncontrolled (`defaultChecked`) so an optimistic |
| 160 | + * toggle is not reverted before the source re-renders. Default false keeps |
| 161 | + * every other consumer read-only. |
| 162 | + */ |
| 163 | + interactiveCheckboxes?: boolean |
| 164 | +``` |
| 165 | + |
| 166 | +- [ ] **Step 2: Build the components object and the input override** |
| 167 | + |
| 168 | +Replace the current body of `MarkdownView` (the `const components = mentions ? {...} : undefined` block and the `return (...)`) with: |
| 169 | + |
| 170 | +```tsx |
| 171 | +export function MarkdownView({ source, className, mentions, interactiveCheckboxes }: Props) { |
| 172 | + const components: Record<string, unknown> = {} |
| 173 | + |
| 174 | + if (mentions) { |
| 175 | + const slugToName = mentions.slugToName |
| 176 | + components.p = ({ children }: { children?: ReactNode }) => ( |
| 177 | + <p>{transformChildren(children, slugToName)}</p> |
| 178 | + ) |
| 179 | + components.li = ({ children }: { children?: ReactNode }) => ( |
| 180 | + <li>{transformChildren(children, slugToName)}</li> |
| 181 | + ) |
| 182 | + } |
| 183 | + |
| 184 | + if (interactiveCheckboxes) { |
| 185 | + components.input = ({ |
| 186 | + type, |
| 187 | + checked, |
| 188 | + }: { |
| 189 | + type?: string |
| 190 | + checked?: boolean |
| 191 | + }) => { |
| 192 | + if (type === 'checkbox') { |
| 193 | + return <input type="checkbox" defaultChecked={Boolean(checked)} readOnly /> |
| 194 | + } |
| 195 | + return <input type={type} /> |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + const hasComponents = Boolean(mentions) || Boolean(interactiveCheckboxes) |
| 200 | + |
| 201 | + return ( |
| 202 | + <div |
| 203 | + className={ |
| 204 | + 'prose prose-sm max-w-none prose-headings:font-semibold prose-headings:text-gray-900 ' + |
| 205 | + 'prose-p:text-gray-800 prose-li:text-gray-800 prose-strong:text-gray-900 ' + |
| 206 | + 'prose-blockquote:border-l-3 prose-blockquote:border-gray-300 prose-blockquote:text-gray-600 ' + |
| 207 | + (className ?? '') |
| 208 | + } |
| 209 | + > |
| 210 | + <ReactMarkdown |
| 211 | + remarkPlugins={[remarkGfm]} |
| 212 | + components={hasComponents ? (components as never) : undefined} |
| 213 | + > |
| 214 | + {source} |
| 215 | + </ReactMarkdown> |
| 216 | + </div> |
| 217 | + ) |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +Rationale: `readOnly` silences React's "controlled checkbox without onChange" warning while still letting the user toggle the box (HTML ignores `readOnly` on checkboxes); the panel's capture-phase click handler reads the post-toggle `checked`. Dropping `disabled` is what makes the click event fire at all. |
| 222 | + |
| 223 | +- [ ] **Step 3: Verify the build compiles** |
| 224 | + |
| 225 | +Run: `npx tsc --noEmit` |
| 226 | +Expected: exits 0. |
| 227 | + |
| 228 | +- [ ] **Step 4: Commit** |
| 229 | + |
| 230 | +```bash |
| 231 | +git add src/components/markdown-view.tsx |
| 232 | +git commit -m "feat: optional interactive checkboxes in MarkdownView |
| 233 | +
|
| 234 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" |
| 235 | +``` |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +### Task 4: Wire the panel — show controls to admins on closed meetings |
| 240 | + |
| 241 | +**Files:** |
| 242 | +- Modify: `src/components/action-items-panel.tsx` |
| 243 | + |
| 244 | +- [ ] **Step 1: Import the predicate** |
| 245 | + |
| 246 | +Add to the imports at the top of `src/components/action-items-panel.tsx`: |
| 247 | + |
| 248 | +```ts |
| 249 | +import { countActionItems, canToggleActionItems } from '@/lib/action-items' |
| 250 | +``` |
| 251 | + |
| 252 | +(replacing the existing `import { countActionItems } from '@/lib/action-items'` line). |
| 253 | + |
| 254 | +- [ ] **Step 2: Compute `canToggle`** |
| 255 | + |
| 256 | +Just after `const { done, total } = countActionItems(source)` (inside the component body), add: |
| 257 | + |
| 258 | +```ts |
| 259 | + const canToggle = canToggleActionItems(meetingStatus, isAdmin) |
| 260 | +``` |
| 261 | + |
| 262 | +- [ ] **Step 3: Gate the click handler on `canToggle`** |
| 263 | + |
| 264 | +Change the first line of `onCheckboxClick` from: |
| 265 | + |
| 266 | +```ts |
| 267 | + if (meetingStatus === 'closed') return |
| 268 | +``` |
| 269 | + |
| 270 | +to: |
| 271 | + |
| 272 | +```ts |
| 273 | + if (!canToggle) return |
| 274 | +``` |
| 275 | + |
| 276 | +- [ ] **Step 4: Show the "Edit list" button to all admins** |
| 277 | + |
| 278 | +Change the button gate from: |
| 279 | + |
| 280 | +```tsx |
| 281 | + {isAdmin && meetingStatus === 'open' && ( |
| 282 | +``` |
| 283 | +
|
| 284 | +to: |
| 285 | +
|
| 286 | +```tsx |
| 287 | + {isAdmin && ( |
| 288 | +``` |
| 289 | +
|
| 290 | +- [ ] **Step 5: Pass interactive checkboxes into the read-only view** |
| 291 | +
|
| 292 | +Change the read-only `<MarkdownView>` call from: |
| 293 | + |
| 294 | +```tsx |
| 295 | + <MarkdownView source={source} mentions={{ slugToName }} /> |
| 296 | +``` |
| 297 | + |
| 298 | +to: |
| 299 | + |
| 300 | +```tsx |
| 301 | + <MarkdownView |
| 302 | + source={source} |
| 303 | + mentions={{ slugToName }} |
| 304 | + interactiveCheckboxes={canToggle} |
| 305 | + /> |
| 306 | +``` |
| 307 | + |
| 308 | +- [ ] **Step 6: Verify it type-checks** |
| 309 | + |
| 310 | +Run: `npx tsc --noEmit` |
| 311 | +Expected: exits 0. |
| 312 | + |
| 313 | +- [ ] **Step 7: Commit** |
| 314 | + |
| 315 | +```bash |
| 316 | +git add src/components/action-items-panel.tsx |
| 317 | +git commit -m "feat: admin action-item controls on closed meetings |
| 318 | + |
| 319 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" |
| 320 | +``` |
| 321 | + |
| 322 | +--- |
| 323 | + |
| 324 | +### Task 5: Full verification |
| 325 | + |
| 326 | +**Files:** none (verification only) |
| 327 | + |
| 328 | +- [ ] **Step 1: Lint** |
| 329 | + |
| 330 | +Run: `npm run lint` |
| 331 | +Expected: no errors. Fix any auto-fixable issues it reports in the files touched above. |
| 332 | + |
| 333 | +- [ ] **Step 2: Unit tests** |
| 334 | + |
| 335 | +Run: `npm test` |
| 336 | +Expected: all tests PASS, including the new `canToggleActionItems` cases. |
| 337 | + |
| 338 | +- [ ] **Step 3: Production build** |
| 339 | + |
| 340 | +Run: `npm run build` |
| 341 | +Expected: build succeeds (required before any PR). |
| 342 | + |
| 343 | +- [ ] **Step 4: Manual smoke check (cannot be unit-tested — no DOM harness)** |
| 344 | + |
| 345 | +Run `npm run dev`, sign in as an **admin**, open a **closed** meeting's detail page, and confirm: |
| 346 | +1. The "Edit list" button is visible (it was hidden before). |
| 347 | +2. Editing the markdown via that button saves (toast "Action items saved"). |
| 348 | +3. Ticking/unticking a checkbox in the read-only list persists after refresh (toast-free; "Saving…" appears briefly). |
| 349 | + |
| 350 | +Then sign in as a **non-admin** (or view as one) and confirm the same closed meeting shows the list **read-only**: no "Edit list" button, and clicking a checkbox does nothing / reverts. |
| 351 | + |
| 352 | +Finally, on an **open** meeting, confirm a non-admin can now toggle a checkbox (the previously-broken path). |
| 353 | + |
| 354 | +- [ ] **Step 5: No commit needed** — verification only. If any step failed, return to the relevant task, fix, and re-run this task. |
| 355 | + |
| 356 | +> **Discovered during final review:** the manual smoke check (Step 4) will FAIL until Task 6's migration is applied — the 027 lock trigger rejects the DB write. Run Step 4 only after the migration is live in the target environment. |
| 357 | + |
| 358 | +--- |
| 359 | + |
| 360 | +### Task 6: Migration — unlock action-items edits on closed meetings |
| 361 | + |
| 362 | +**Files:** |
| 363 | +- Create: `scripts/prod/migrations/034_meetings_action_items_unlock.sql` |
| 364 | + |
| 365 | +**Why:** The 027 `fn_meetings_lock_closed` BEFORE-UPDATE trigger raises `'meeting is closed; reopen it before editing'` for any update to a closed meeting except a clean reopen. That blocks both `updateActionItems` and `toggleActionItem` for admins on closed meetings, so the app-layer changes (Tasks 1–4) don't work end-to-end without this. Scope: action-items-only (decided) — admins still reopen to edit anything else. `meetings.meetings` columns at time of writing: `id, title, meeting_date, status, random_seed, linked_poll_id, action_items_md, created_by, created_at, closed_at, closed_by, agenda_md`. |
| 366 | +
|
| 367 | +- [ ] **Step 1: Write the migration** |
| 368 | +
|
| 369 | +Create `scripts/prod/migrations/034_meetings_action_items_unlock.sql` that `CREATE OR REPLACE`s `public.fn_meetings_lock_closed()` (trigger binding from 027 is left intact), keeping the existing reopen branch and adding a second allowed branch: `new.status = 'closed'` AND every column except `action_items_md` equal to its `old` value (`=` for non-null columns, `is not distinct from` for nullable ones: `linked_poll_id`, `agenda_md`, `closed_at`, `closed_by`). Otherwise still `raise exception 'meeting is closed; reopen it before editing'`. Wrap in `begin; … commit;` and end with `notify pgrst, 'reload schema';`, matching the 027 file style. |
| 370 | +
|
| 371 | +- [ ] **Step 2: Verify the SQL parses (no live DB needed here)** |
| 372 | +
|
| 373 | +This repo applies migrations manually against Supabase. Sanity-check syntax by eye against 027 (same function shape). The build/test/lint suite does not execute SQL, so there is no automated gate; correctness is confirmed by the post-apply manual smoke check (Task 5, Step 4). |
| 374 | +
|
| 375 | +- [ ] **Step 3: Commit** |
| 376 | +
|
| 377 | +```bash |
| 378 | +git add scripts/prod/migrations/034_meetings_action_items_unlock.sql |
| 379 | +git commit -m "feat(db): allow admins to edit action items on closed meetings |
| 380 | + |
| 381 | +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" |
| 382 | +``` |
| 383 | + |
| 384 | +- [ ] **Step 4: Apply** — the repo owner runs the migration against staging/prod via their normal Supabase process. (Not done by the implementer.) |
| 385 | + |
| 386 | +--- |
| 387 | + |
| 388 | +## Notes for the implementer |
| 389 | + |
| 390 | +- **A DB migration IS required** (see Task 6). The original draft of this plan said "do not add a migration," reasoning only about RLS — but the 027 `fn_meetings_lock_closed` BEFORE-UPDATE trigger blocks all closed-meeting updates except a clean reopen, which defeats the app-layer changes. The `meetings_update_admin` RLS policy (`028_meetings_rls.sql:40`) does grant admins the row update; non-admin closed-meeting writes stay blocked at both the server (`canToggleActionItems`) and RLS layers — defense in depth, unchanged. |
| 391 | +- **`updateActionItems`** needs no change: it is already admin-only with no status check. |
| 392 | +- The two server pages (`src/app/(app)/meetings/[id]/page.tsx` and `src/app/(app)/admin/meetings/[id]/page.tsx`) already pass `meetingStatus` and `isAdmin` into `ActionItemsPanel` — no changes needed there. |
0 commit comments