feat: collections, watchlists, and shareable links (v0.9.0)

Phase 3 completion — Personal Workflow feature set is now complete.

Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
  collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
  copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)

Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash

AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS

Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jack Levy
2026-03-01 23:23:45 -05:00
parent 22b68f9502
commit 9e5ac9b33d
21 changed files with 1429 additions and 7 deletions

View File

@@ -3,6 +3,9 @@ import type {
Bill,
BillAction,
BillDetail,
BriefSchema,
Collection,
CollectionDetail,
DashboardData,
Follow,
Member,
@@ -86,6 +89,33 @@ export const billsAPI = {
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
// Collections
export const collectionsAPI = {
list: () =>
apiClient.get<Collection[]>("/api/collections").then((r) => r.data),
create: (name: string, is_public: boolean) =>
apiClient.post<Collection>("/api/collections", { name, is_public }).then((r) => r.data),
get: (id: number) =>
apiClient.get<CollectionDetail>(`/api/collections/${id}`).then((r) => r.data),
update: (id: number, data: { name?: string; is_public?: boolean }) =>
apiClient.patch<Collection>(`/api/collections/${id}`, data).then((r) => r.data),
delete: (id: number) => apiClient.delete(`/api/collections/${id}`),
addBill: (id: number, bill_id: string) =>
apiClient.post(`/api/collections/${id}/bills/${bill_id}`).then((r) => r.data),
removeBill: (id: number, bill_id: string) =>
apiClient.delete(`/api/collections/${id}/bills/${bill_id}`),
getByShareToken: (token: string) =>
apiClient.get<CollectionDetail>(`/api/collections/share/${token}`).then((r) => r.data),
};
// Share (public)
export const shareAPI = {
getBrief: (token: string) =>
apiClient.get<{ brief: BriefSchema; bill: Bill }>(`/api/share/brief/${token}`).then((r) => r.data),
getCollection: (token: string) =>
apiClient.get<CollectionDetail>(`/api/share/collection/${token}`).then((r) => r.data),
};
// Notes
export const notesAPI = {
get: (billId: string) =>