Skip to content

Commit cc4e90f

Browse files
committed
feat: wire real agency docket ops and reconcile agency/public rls
1 parent 0679f12 commit cc4e90f

7 files changed

Lines changed: 379 additions & 53 deletions

File tree

docs/plan/M01-schema-and-contract-reconciliation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ Align database schema/RPC contracts with application code expectations.
4848
- 2026-02-11: Enabled baseline RLS policies and grants for new tenant-bound tables and RPC execution.
4949
- 2026-02-11: Added follow-up migration `20260211000200_public_submission_hardening.sql` for `dockets.identity_mode` policy constraints and submission rate-limit indexes.
5050
- 2026-02-11: Added follow-up migration `20260211000300_api_rate_limiting.sql` for reusable API route throttling primitives (`api_rate_limits`, `check_api_rate_limit`).
51+
- 2026-02-11: Added follow-up migration `20260211000400_agency_rls_reconciliation.sql` to align dockets/comments/legacy attachments RLS with agency membership and platform role model.

docs/plan/M03-agency-operations.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Replace mock agency workflows with real data operations.
1616

1717
- [x] Wire `/agency/users` to real user management page.
1818
- [x] Remove high-visibility "coming soon" placeholders in core agency routes.
19-
- [ ] Connect docket pages to real Supabase tables/RPCs.
19+
- [x] Connect docket pages to real Supabase tables/RPCs.
2020
- [ ] Ensure moderation queue reads/writes real moderation data.
2121

2222
## Acceptance criteria
@@ -38,3 +38,5 @@ Replace mock agency workflows with real data operations.
3838
- 2026-02-11: Updated app routing so `/agency/users` now renders `UserManagement` instead of placeholder content.
3939
- 2026-02-11: Updated moderation routing and queue behavior so `/agency/moderation/flagged` opens real moderation queue with flagged-tab default selection.
4040
- 2026-02-11: Replaced public placeholder screens for data access and platform status with implemented content pages.
41+
- 2026-02-11: Replaced mock data in `src/pages/agency/DocketList.tsx` with real Supabase queries scoped to current agency membership.
42+
- 2026-02-11: Implemented real docket creation in `src/pages/agency/DocketWizard.tsx` including identity-mode, moderation/captcha settings, and best-effort supporting document persistence.

docs/plan/M04-public-commenting-workflow.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Deliver complete public submission workflow with configurable identity policy.
1616

1717
- [x] Add per-docket identity mode setting.
1818
- [x] Enforce submission mode in backend logic.
19-
- [ ] Complete upload + attachment record consistency.
19+
- [x] Complete upload + attachment record consistency.
2020
- [x] Ensure deterministic submission status creation.
2121

2222
## Acceptance criteria
@@ -38,3 +38,4 @@ Deliver complete public submission workflow with configurable identity policy.
3838
- 2026-02-11: Added migration `20260211000200_public_submission_hardening.sql` introducing `dockets.identity_mode` with enforced allowed values and rate-limit indexes for comments.
3939
- 2026-02-11: Added edge function `supabase/functions/submit-comment/index.ts` for server-side comment submission with hCaptcha verification, identity-mode enforcement, per-docket limits, IP rate limiting, and deterministic status assignment.
4040
- 2026-02-11: Updated `src/pages/public/CommentWizard.tsx` to submit through server function, enforce per-docket identity requirements in UX/validation, and support authenticated-required dockets without hardcoded global login redirects.
41+
- 2026-02-11: Hardened attachment lifecycle in `src/pages/public/CommentWizard.tsx` by removing uploaded files from storage when metadata insert fails, preventing orphaned attachment objects.

src/pages/agency/DocketList.tsx

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
import React, { useState, useEffect } from 'react'
2-
import { Link } from 'react-router-dom'
2+
import { Link, useNavigate } from 'react-router-dom'
33
import { usePermissions } from '../../hooks/usePermissions'
44
import { useAgency } from '../../contexts/AgencyContext'
55
import { PermissionButton } from '../../components/PermissionGate'
6+
import { supabase } from '../../lib/supabase'
67
import {
78
Search,
89
Filter,
910
Plus,
1011
Calendar,
1112
MessageSquare,
1213
Clock,
13-
Archive,
1414
Eye,
1515
Edit3
1616
} from 'lucide-react'
1717

1818
interface Docket {
1919
id: string
2020
title: string
21-
status: 'open' | 'closed' | 'archived'
21+
status: 'draft' | 'open' | 'closed' | 'archived'
2222
open_date: string
2323
close_date?: string
2424
comment_count: number
@@ -29,57 +29,68 @@ interface Docket {
2929
type AriaSortValue = 'none' | 'ascending' | 'descending'
3030

3131
const DocketList = () => {
32+
const navigate = useNavigate()
3233
const { currentAgency } = useAgency()
3334
const { hasPermission } = usePermissions(currentAgency?.id)
3435
const [dockets, setDockets] = useState<Docket[]>([])
3536
const [loading, setLoading] = useState(true)
3637
const [searchQuery, setSearchQuery] = useState('')
37-
const [statusFilter, setStatusFilter] = useState<'all' | 'open' | 'closed' | 'archived'>('all')
38+
const [statusFilter, setStatusFilter] = useState<'all' | 'draft' | 'open' | 'closed' | 'archived'>('all')
3839
const [currentPage, setCurrentPage] = useState(1)
3940
const [sortField, setSortField] = useState<keyof Docket>('last_activity')
4041
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
4142

42-
// TODO: Replace with actual API call
4343
useEffect(() => {
4444
const fetchDockets = async () => {
45+
if (!currentAgency?.id) {
46+
setDockets([])
47+
setLoading(false)
48+
return
49+
}
50+
4551
setLoading(true)
4652
try {
47-
// Mock data - replace with actual supabase query
48-
const mockDockets: Docket[] = [
49-
{
50-
id: '1',
51-
title: 'Downtown Parking Regulations Update',
52-
status: 'open',
53-
open_date: '2024-01-15T09:00:00Z',
54-
close_date: '2024-02-15T17:00:00Z',
55-
comment_count: 23,
56-
last_activity: '2024-01-20T14:30:00Z',
57-
created_by: 'current_user'
58-
},
59-
{
60-
id: '2',
61-
title: 'City Budget 2024 Public Review',
62-
status: 'open',
63-
open_date: '2024-01-10T08:00:00Z',
64-
close_date: '2024-02-10T23:59:00Z',
65-
comment_count: 156,
66-
last_activity: '2024-01-21T11:15:00Z',
67-
created_by: 'other_user'
68-
},
69-
{
70-
id: '3',
71-
title: 'New Housing Development Proposal',
72-
status: 'closed',
73-
open_date: '2023-12-01T09:00:00Z',
74-
close_date: '2023-12-31T17:00:00Z',
75-
comment_count: 89,
76-
last_activity: '2023-12-31T16:45:00Z',
77-
created_by: 'current_user'
53+
const { data, error } = await supabase
54+
.from('dockets')
55+
.select(`
56+
id,
57+
title,
58+
status,
59+
open_at,
60+
close_at,
61+
created_at,
62+
updated_at,
63+
created_by,
64+
comments (count)
65+
`)
66+
.eq('agency_id', currentAgency.id)
67+
.is('deleted_at', null)
68+
.order('updated_at', { ascending: false })
69+
70+
if (error) {
71+
throw error
72+
}
73+
74+
const formatted = (data || []).map((docket) => {
75+
const countRelation = Array.isArray(docket.comments) ? docket.comments[0] : docket.comments
76+
const commentCount = typeof countRelation?.count === 'number' ? countRelation.count : 0
77+
78+
return {
79+
id: docket.id,
80+
title: docket.title,
81+
status: (docket.status || 'draft') as Docket['status'],
82+
open_date: docket.open_at || docket.created_at,
83+
close_date: docket.close_at || undefined,
84+
comment_count: commentCount,
85+
last_activity: docket.updated_at || docket.created_at,
86+
created_by: docket.created_by || ''
7887
}
79-
]
80-
setDockets(mockDockets)
88+
})
89+
90+
setDockets(formatted)
8191
} catch (error) {
8292
console.error('Error fetching dockets:', error)
93+
setDockets([])
8394
} finally {
8495
setLoading(false)
8596
}
@@ -90,8 +101,7 @@ const DocketList = () => {
90101

91102
const handleSearch = (e: React.FormEvent) => {
92103
e.preventDefault()
93-
// TODO: Implement search functionality
94-
console.log('Searching for:', searchQuery)
104+
setCurrentPage(1)
95105
}
96106

97107
const handleSort = (field: keyof Docket) => {
@@ -119,6 +129,8 @@ const DocketList = () => {
119129
return `${baseClasses} bg-red-100 text-red-800`
120130
case 'archived':
121131
return `${baseClasses} bg-gray-100 text-gray-800`
132+
case 'draft':
133+
return `${baseClasses} bg-yellow-100 text-yellow-800`
122134
default:
123135
return `${baseClasses} bg-gray-100 text-gray-800`
124136
}
@@ -177,6 +189,7 @@ const DocketList = () => {
177189
</div>
178190
<PermissionButton
179191
permission="create_thread"
192+
onClick={() => navigate('/agency/dockets/new')}
180193
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 transition-colors"
181194
>
182195
<Plus className="w-4 h-4 mr-2" />
@@ -213,11 +226,12 @@ const DocketList = () => {
213226
</div>
214227
<select
215228
value={statusFilter}
216-
onChange={(e) => setStatusFilter(e.target.value as any)}
229+
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'draft' | 'open' | 'closed' | 'archived')}
217230
className="block w-full pl-10 pr-8 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
218231
aria-label="Filter by status"
219232
>
220233
<option value="all">All Status</option>
234+
<option value="draft">Draft</option>
221235
<option value="open">Open</option>
222236
<option value="closed">Closed</option>
223237
<option value="archived">Archived</option>
@@ -245,6 +259,7 @@ const DocketList = () => {
245259
{(!searchQuery && statusFilter === 'all') && (
246260
<PermissionButton
247261
permission="create_thread"
262+
onClick={() => navigate('/agency/dockets/new')}
248263
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-700 rounded-md hover:bg-blue-800 transition-colors"
249264
>
250265
<Plus className="w-4 h-4 mr-2" />

src/pages/agency/DocketWizard.tsx

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState } from 'react'
22
import { useNavigate } from 'react-router-dom'
33
import { useAgency } from '../../contexts/AgencyContext'
4+
import { supabase } from '../../lib/supabase'
45
import {
56
ChevronLeft,
67
ChevronRight,
@@ -164,17 +165,103 @@ const DocketWizard = () => {
164165

165166
const handleSubmit = async () => {
166167
if (!validateStep(3)) return
168+
if (!currentAgency?.id) {
169+
setErrors({ submit: 'No agency selected. Please choose an agency and try again.' })
170+
return
171+
}
167172

168173
setIsSubmitting(true)
169174
try {
170-
// TODO: Submit to backend API
171-
console.log('Submitting docket:', formData)
172-
173-
// Mock API delay
174-
await new Promise(resolve => setTimeout(resolve, 2000))
175-
176-
// Navigate to the new docket detail page
177-
navigate('/agency/dockets/new-docket-id')
175+
const extensionToMime: Record<string, string> = {
176+
pdf: 'application/pdf',
177+
doc: 'application/msword',
178+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
179+
txt: 'text/plain',
180+
jpg: 'image/jpeg',
181+
jpeg: 'image/jpeg',
182+
png: 'image/png',
183+
gif: 'image/gif'
184+
}
185+
186+
const openAt = new Date(`${formData.openDate}T${formData.openTime || '00:00'}`).toISOString()
187+
const closeAt = formData.closeDate
188+
? new Date(`${formData.closeDate}T${formData.closeTime || '23:59'}`).toISOString()
189+
: null
190+
const nowIso = new Date().toISOString()
191+
192+
const docketStatus = openAt <= nowIso ? 'open' : 'draft'
193+
const allowedMimeTypes = formData.allowedFileTypes
194+
.map((ext) => extensionToMime[ext])
195+
.filter(Boolean)
196+
197+
const docketPayload = {
198+
agency_id: currentAgency.id,
199+
title: formData.title.trim(),
200+
summary: formData.summary.trim(),
201+
description: formData.summary.trim(),
202+
slug: formData.urlSlug.trim(),
203+
reference_code: formData.referenceCode.trim() || null,
204+
tags: formData.tags,
205+
status: docketStatus,
206+
open_at: openAt,
207+
close_at: closeAt,
208+
comment_deadline: closeAt,
209+
max_file_size_mb: formData.maxFileSize,
210+
allowed_file_types: formData.allowedFileTypes,
211+
allowed_mime_types: allowedMimeTypes,
212+
require_captcha: formData.requireCaptcha,
213+
identity_mode: formData.identityMode,
214+
auto_publish: formData.autoPublish,
215+
uploads_enabled: true
216+
}
217+
218+
const { data: newDocket, error: createError } = await supabase
219+
.from('dockets')
220+
.insert(docketPayload)
221+
.select('id')
222+
.single()
223+
224+
if (createError || !newDocket) {
225+
throw createError || new Error('Failed to create docket')
226+
}
227+
228+
let failedDocUploads = 0
229+
for (const file of formData.supportingDocs) {
230+
const fileExtension = file.name.split('.').pop() || 'bin'
231+
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_')
232+
const fileName = `${crypto.randomUUID()}-${safeName}`
233+
const filePath = `agency/${currentAgency.id}/dockets/${newDocket.id}/${fileName}`
234+
235+
const { error: uploadError } = await supabase.storage
236+
.from('agency-assets')
237+
.upload(filePath, file, { upsert: false })
238+
239+
if (uploadError) {
240+
failedDocUploads += 1
241+
continue
242+
}
243+
244+
const { error: metadataError } = await supabase
245+
.from('attachments')
246+
.insert({
247+
docket_id: newDocket.id,
248+
file_path: filePath,
249+
file_type: file.type || extensionToMime[fileExtension] || 'application/octet-stream',
250+
file_size: file.size
251+
})
252+
253+
if (metadataError) {
254+
failedDocUploads += 1
255+
await supabase.storage.from('agency-assets').remove([filePath])
256+
}
257+
}
258+
259+
if (failedDocUploads > 0) {
260+
navigate(`/agency/dockets/${newDocket.id}?upload_warnings=${failedDocUploads}`)
261+
return
262+
}
263+
264+
navigate(`/agency/dockets/${newDocket.id}`)
178265
} catch (error) {
179266
console.error('Error creating docket:', error)
180267
setErrors({ submit: 'Failed to create docket. Please try again.' })

src/pages/public/CommentWizard.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,9 @@ const CommentWizard = () => {
318318
const commentId = submitResult.data.comment_id as string;
319319
const trackingId = (submitResult.data.tracking_id as string) || `CMT-${Date.now().toString(36).toUpperCase()}`;
320320

321-
// Upload files if any
321+
// Upload files if any. Keep storage and DB metadata consistent by cleaning up
322+
// uploaded objects when metadata persistence fails.
323+
let failedAttachmentCount = 0
322324
if (formData.files.length > 0) {
323325
for (const file of formData.files) {
324326
const fileExtension = file.name.split('.').pop();
@@ -332,6 +334,7 @@ const CommentWizard = () => {
332334

333335
if (uploadError) {
334336
console.error('File upload error:', uploadError);
337+
failedAttachmentCount += 1
335338
continue;
336339
}
337340

@@ -341,7 +344,7 @@ const CommentWizard = () => {
341344
.getPublicUrl(filePath);
342345

343346
// Save attachment record
344-
await supabase
347+
const { error: attachmentError } = await supabase
345348
.from('comment_attachments')
346349
.insert({
347350
comment_id: commentId,
@@ -351,10 +354,22 @@ const CommentWizard = () => {
351354
file_size: file.size,
352355
mime_type: file.type
353356
});
357+
358+
if (attachmentError) {
359+
failedAttachmentCount += 1
360+
await supabase.storage
361+
.from('comment-attachments')
362+
.remove([filePath])
363+
}
354364
}
355365
}
356366

357367
// Redirect to thank you page
368+
if (failedAttachmentCount > 0) {
369+
navigate(`/thank-you?tracking=${trackingId}&attachment_warnings=${failedAttachmentCount}`);
370+
return
371+
}
372+
358373
navigate(`/thank-you?tracking=${trackingId}`);
359374

360375
} catch (err) {

0 commit comments

Comments
 (0)