Skip to content

Commit 3faf840

Browse files
authored
Allow admins to edit action items on closed meetings (#18)
1 parent e4eb4b0 commit 3faf840

8 files changed

Lines changed: 708 additions & 17 deletions
Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
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-testedno 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: Migrationunlock 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 RLSbut 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

Comments
 (0)