From af42ea46aa633ad34532531e5a8d091f709d782f Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 20:42:15 -0400 Subject: [PATCH 01/11] Use React Router Form for save-changes submission Convert the "Save changes" button on the review detail page from a bare onClick handler to a , matching the approve/reject actions that already use
. The payload is a nested object rather than flat fields, so it's still sent as JSON via fetcher.submit, but routing it through a real form gives us proper submit semantics (Enter-to-submit, pending state, progressive enhancement) instead of a plain button click. Also swap the deprecated FormEvent type for SubmitEvent, and convert logout to a POST action (was a GET loader) triggered via a in the sidebar, since a GET route shouldn't perform a mutation like signing out. --- app/routes/authenticated/_layout.tsx | 57 ++++++++++++--------- app/routes/authenticated/logout.tsx | 16 ++++-- app/routes/authenticated/reviews/detail.tsx | 28 ++++++---- 3 files changed, 62 insertions(+), 39 deletions(-) diff --git a/app/routes/authenticated/_layout.tsx b/app/routes/authenticated/_layout.tsx index 959568c..9cc5309 100644 --- a/app/routes/authenticated/_layout.tsx +++ b/app/routes/authenticated/_layout.tsx @@ -1,7 +1,7 @@ import { Dashboard, Logout, RateReview } from "@mui/icons-material"; import { Box } from "@mui/material"; import { styled } from "@mui/material/styles"; -import { Link, NavLink, Outlet } from "react-router"; +import { Form, NavLink, Outlet } from "react-router"; import phlasklogo from "~/assets/PHLASK_v2.svg"; import { ThemeToggle } from "~/components/ThemeToggle"; import { WaveDivider } from "~/components/WaveDivider"; @@ -168,30 +168,37 @@ export default function DashboardLayout() { - ({ - display: "flex", - alignItems: "center", - gap: 1.25, - borderRadius: 3, - px: 2, - py: 1.25, - fontSize: "0.875rem", - fontWeight: 500, - textDecoration: "none", - color: navy[600], - transition: "background-color 0.2s ease", - "&:hover": { bgcolor: brand[50] }, - ...theme.applyStyles("dark", { - color: `${brand[100]}cc`, - "&:hover": { bgcolor: navy[800] }, - }), - })} - > - - Logout + + ({ + display: "flex", + width: "100%", + alignItems: "center", + gap: 1.25, + borderRadius: 3, + border: "none", + bgcolor: "transparent", + px: 2, + py: 1.25, + fontSize: "0.875rem", + fontFamily: "inherit", + fontWeight: 500, + textAlign: "left", + cursor: "pointer", + color: navy[600], + transition: "background-color 0.2s ease", + "&:hover": { bgcolor: brand[50] }, + ...theme.applyStyles("dark", { + color: `${brand[100]}cc`, + "&:hover": { bgcolor: navy[800] }, + }), + })} + > + + Logout + diff --git a/app/routes/authenticated/logout.tsx b/app/routes/authenticated/logout.tsx index e2d000d..e0ea3db 100644 --- a/app/routes/authenticated/logout.tsx +++ b/app/routes/authenticated/logout.tsx @@ -1,10 +1,18 @@ -import { type LoaderFunction, redirect } from "react-router"; +import { + type ActionFunction, + type LoaderFunction, + redirect, +} from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -export const loader: LoaderFunction = async ({ request }) => { - const { client } = getDatabaseClient(request); +// Signing out is a mutation, so it belongs in the action (POST), not a GET +// loader. A direct GET to this route just bounces back to the dashboard. +export const loader: LoaderFunction = () => redirect("/"); + +export const action: ActionFunction = async ({ request }) => { + const { client, headers } = getDatabaseClient(request); await client.auth.signOut(); - return redirect("/auth"); + return redirect("/auth", { headers }); }; diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 5669d93..65d6544 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -16,7 +16,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useEffect, useState } from "react"; +import { type SubmitEvent, useEffect, useState } from "react"; import { type ActionFunction, data, @@ -282,7 +282,13 @@ const ReviewDetail = () => { value: EditableValues["bathroom"][K], ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); - const handleSave = () => { + const handleSave = (event: SubmitEvent) => { + // The payload is a nested object (not flat form fields), so it's sent as + // JSON via the fetcher rather than a native form-encoded submission — + // still routed through a real below for the submit + // semantics (Enter-to-submit, pending state, progressive enhancement). + event.preventDefault(); + const payload: Partial = { name: values.name || null, resource_type: values.resource_type, @@ -776,14 +782,16 @@ const ReviewDetail = () => { {isPending && ( - + + + From 134a552266b4e073c81b4bf67cb9bda9a8171a39 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:07:05 -0400 Subject: [PATCH 02/11] Add resource edit history and counts to API and dashboard - Introduced new API methods for fetching edit counts and change logs, enhancing the resource edit management capabilities. - Updated the dashboard to utilize these new methods, allowing for a comprehensive view of pending edits and historical changes. - Modified the reviews detail page to display the change log for individual resources, improving audit trail visibility. - Enhanced the reviews index page to show pending edit counts for resources, aiding in resource management and review prioritization. --- app/api/resource-edits/methods.ts | 42 ++++++++++++++++++ app/routes/authenticated/dashboard.tsx | 45 ++++++++++++------- app/routes/authenticated/reviews/detail.tsx | 49 +++++++++++++++++++-- app/routes/authenticated/reviews/index.tsx | 41 ++++++++++++++--- app/types/ResourceEdit.ts | 25 +++++++++++ 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index d76a1dc..266dccf 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,7 +1,9 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { EditReviewStatus, + ResourceChangeLogEntry, ResourceEdit, + ResourceEditCount, ResourceEditQueueRow, } from "~/types/ResourceEdit"; import type { ResourceEntry } from "~/types/ResourceEntry"; @@ -38,6 +40,46 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceEdit[]; }, + /** Count of PENDING edits per resource — flags resources with multiple + * competing proposed edits. Backed by `resource_edit_counts`. */ + getEditCounts: async () => { + const { data, error } = await client + .from("resource_edit_counts") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceEditCount[]; + }, + /** Full history of decided (APPROVED/REJECTED) edits, most recent first + * within each resource. Backed by `resource_change_log`. */ + getChangeLog: async () => { + const { data, error } = await client + .from("resource_change_log") + .select("*"); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, + /** Decided edit history for a single resource, e.g. for the review + * detail page's audit trail. Backed by `resource_change_log`. */ + getChangeLogForResource: async (resourceId: number) => { + const { data, error } = await client + .from("resource_change_log") + .select("*") + .eq("resource_id", resourceId); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceChangeLogEntry[]; + }, getList: async (params: { review_status?: EditReviewStatus } = {}) => { let query = table.select("*").order("submitted_at", { ascending: false }); diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 4323b0c..d90430f 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -29,29 +29,40 @@ const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const edits = await editAPI.getList(); + + // Every `resource_edits` row is either still PENDING (covered by the two + // queue views) or has been decided (covered by `resource_change_log`), so + // this trio fully reconstructs the stats below without ever scanning the + // full `resource_edits` table (with all its wide proposed-value columns). + const [pendingEdits, newResources, changeLog] = await Promise.all([ + editAPI.getEditsQueue(), + editAPI.getNewResourcesQueue(), + editAPI.getChangeLog(), + ]); + + const pending = [...pendingEdits, ...newResources]; const submitterCounts = new Map(); const approverCounts = new Map(); const outstandingByType = new Map(); - let pendingCount = 0; - for (const edit of edits) { - const creator = edit.creator || "Unknown"; - submitterCounts.set(creator, (submitterCounts.get(creator) ?? 0) + 1); + for (const edit of pending) { + const submitter = edit.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); + outstandingByType.set( + edit.resource_type, + (outstandingByType.get(edit.resource_type) ?? 0) + 1, + ); + } - if (edit.review_status === "PENDING") { - pendingCount += 1; - outstandingByType.set( - edit.resource_type, - (outstandingByType.get(edit.resource_type) ?? 0) + 1, - ); - } + for (const entry of changeLog) { + const submitter = entry.submitted_by || "Unknown"; + submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); - if (edit.review_status === "APPROVED" && edit.reviewed_by) { + if (entry.review_status === "APPROVED" && entry.reviewed_by) { approverCounts.set( - edit.reviewed_by, - (approverCounts.get(edit.reviewed_by) ?? 0) + 1, + entry.reviewed_by, + (approverCounts.get(entry.reviewed_by) ?? 0) + 1, ); } } @@ -72,8 +83,8 @@ export const loader: LoaderFunction = async ({ request }) => { })); return { - totalEdits: edits.length, - pendingCount, + totalEdits: pending.length + changeLog.length, + pendingCount: pending.length, topSubmitters, topApprovers, outstanding, diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 65d6544..dd54db6 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -33,7 +33,7 @@ import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit } from "~/types/ResourceEdit"; +import type { ResourceChangeLogEntry, ResourceEdit } from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -173,6 +173,7 @@ export const loader: LoaderFunction = async ({ request, params }) => { const edit = await editAPI.getById(id); let resource: ResourceEntry | null = null; + let changeLog: ResourceChangeLogEntry[] = []; if (edit.mapped_resource !== null) { try { const resourceAPI = getResourceEntryAPI(client); @@ -180,9 +181,11 @@ export const loader: LoaderFunction = async ({ request, params }) => { } catch { resource = null; } + + changeLog = await editAPI.getChangeLogForResource(edit.mapped_resource); } - return { edit, resource }; + return { edit, resource, changeLog }; }; export const action: ActionFunction = async ({ request, params, context }) => { @@ -238,9 +241,10 @@ export const action: ActionFunction = async ({ request, params, context }) => { }; const ReviewDetail = () => { - const { edit, resource } = useLoaderData<{ + const { edit, resource, changeLog } = useLoaderData<{ edit: ResourceEdit; resource: ResourceEntry | null; + changeLog: ResourceChangeLogEntry[]; }>(); const actionData = useActionData<{ message?: string }>(); const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); @@ -386,6 +390,45 @@ const ReviewDetail = () => { )} + {!isNew && changeLog.length > 0 && ( + + + Change history + + + + + {changeLog.map((entry) => ( + + + + + + {entry.reviewed_by ?? "—"} + + + {entry.reviewed_at + ? new Date(entry.reviewed_at).toLocaleString( + undefined, + { dateStyle: "medium", timeStyle: "short" }, + ) + : "—"} + + + {entry.review_notes ?? "—"} + + + ))} + +
+
+
+ )} + diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index cb32608..7e50fa2 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -22,7 +22,11 @@ import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit, ResourceEditQueueRow } from "~/types/ResourceEdit"; +import type { + ResourceEdit, + ResourceEditCount, + ResourceEditQueueRow, +} from "~/types/ResourceEdit"; import { resourceTypeChipColor, resourceTypeChipIcon, @@ -34,12 +38,13 @@ export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - const [edits, newResources] = await Promise.all([ + const [edits, newResources, editCounts] = await Promise.all([ editAPI.getEditsQueue(), editAPI.getNewResourcesQueue(), + editAPI.getEditCounts(), ]); - return { edits, newResources }; + return { edits, newResources, editCounts }; }; type RowData = { @@ -49,6 +54,8 @@ type RowData = { resource_type: ResourceEdit["resource_type"]; resourceLabel: string; submitted_at: string; + /** How many PENDING edits (including this one) target the same resource. */ + competingEdits: number; }; const columns: ColumnDef[] = [ @@ -85,6 +92,20 @@ const columns: ColumnDef[] = [ accessorKey: "resourceLabel", header: "Existing resource", }, + { + accessorKey: "competingEdits", + header: "Pending edits", + cell: ({ row }) => + row.original.competingEdits > 1 ? ( + + ) : ( + row.original.competingEdits || "—" + ), + }, { accessorKey: "submitted_at", header: "Submitted", @@ -97,15 +118,21 @@ const columns: ColumnDef[] = [ ]; const ReviewsQueue = () => { - const { edits, newResources } = useLoaderData<{ + const { edits, newResources, editCounts } = useLoaderData<{ edits: ResourceEditQueueRow[]; newResources: ResourceEdit[]; + editCounts: ResourceEditCount[]; }>(); const navigate = useNavigate(); const [sorting, setSorting] = useState([ { id: "submitted_at", desc: true }, ]); + const pendingCountByResource = useMemo( + () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), + [editCounts], + ); + const data: RowData[] = useMemo( () => [ ...edits.map((edit) => ({ @@ -118,6 +145,9 @@ const ReviewsQueue = () => { edit.current_resource?.address || `Resource #${edit.mapped_resource}`, submitted_at: edit.submitted_at, + competingEdits: edit.mapped_resource + ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) + : 0, })), ...newResources.map((edit) => ({ id: edit.id, @@ -126,9 +156,10 @@ const ReviewsQueue = () => { resource_type: edit.resource_type, resourceLabel: "—", submitted_at: edit.submitted_at, + competingEdits: 0, })), ], - [edits, newResources], + [edits, newResources, pendingCountByResource], ); const table = useReactTable({ diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts index 934482a..79fe672 100644 --- a/app/types/ResourceEdit.ts +++ b/app/types/ResourceEdit.ts @@ -31,3 +31,28 @@ export type ResourceEdit = ResourceEntry & { export type ResourceEditQueueRow = ResourceEdit & { current_resource: ResourceEntry & { id: number }; }; + +/** + * A row from `resource_edit_counts` — the number of PENDING edits currently + * queued against a given resource. Useful for flagging resources with + * multiple competing proposed edits before a reviewer picks one to approve. + */ +export type ResourceEditCount = { + resource_id: number; + pending_count: number; +}; + +/** + * A row from `resource_change_log` — a decided (APPROVED or REJECTED) edit, + * stripped down to just the review-lifecycle columns. One row per past + * decision, ordered by `resource_id` then most-recently-reviewed first. + */ +export type ResourceChangeLogEntry = { + edit_id: number; + resource_id: number | null; + review_status: EditReviewStatus; + submitted_by: string | null; + reviewed_by: string | null; + reviewed_at: string | null; + review_notes: string | null; +}; From 4a88ea5fb8190eba8f62f4e91fde2b00d82104b8 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:35:48 -0400 Subject: [PATCH 03/11] Refactor reviews index to enhance data structure and presentation - Introduced separate types for edit and new row data, improving type safety and clarity. - Updated table columns to include a submitted date for both edits and new resources, enhancing the review process. - Refactored the ReviewQueueTable component to handle sorting and rendering more efficiently. - Improved the overall layout by incorporating a Stack component for better spacing and organization. --- app/routes/authenticated/reviews/index.tsx | 266 +++++++++++++-------- 1 file changed, 162 insertions(+), 104 deletions(-) diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 7e50fa2..a158234 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -1,6 +1,7 @@ import { Chip, Paper, + Stack, Table, TableBody, TableCell, @@ -47,9 +48,8 @@ export const loader: LoaderFunction = async ({ request }) => { return { edits, newResources, editCounts }; }; -type RowData = { +type EditRowData = { id: number; - kind: "EDIT" | "NEW"; name: string | null; resource_type: ResourceEdit["resource_type"]; resourceLabel: string; @@ -58,19 +58,24 @@ type RowData = { competingEdits: number; }; -const columns: ColumnDef[] = [ - { - accessorKey: "kind", - header: "Kind", - cell: ({ row }) => ( - - ), - }, +type NewRowData = { + id: number; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + submitted_at: string; +}; + +const submittedColumn = (): ColumnDef => ({ + accessorKey: "submitted_at", + header: "Submitted", + cell: ({ row }) => + new Date(row.original.submitted_at).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), +}); + +const editColumns: ColumnDef[] = [ { accessorKey: "name", header: "Proposed name", @@ -106,38 +111,125 @@ const columns: ColumnDef[] = [ row.original.competingEdits || "—" ), }, + submittedColumn(), +]; + +const newColumns: ColumnDef[] = [ { - accessorKey: "submitted_at", - header: "Submitted", - cell: ({ row }) => - new Date(row.original.submitted_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), + accessorKey: "name", + header: "Proposed name", + cell: ({ row }) => row.original.name || "—", + }, + { + accessorKey: "resource_type", + header: "Type", + cell: ({ row }) => ( + + ), }, + submittedColumn(), ]; +type ReviewQueueTableProps = { + data: T[]; + columns: ColumnDef[]; + emptyMessage: string; +}; + +function ReviewQueueTable({ + data, + columns, + emptyMessage, +}: ReviewQueueTableProps) { + const navigate = useNavigate(); + const [sorting, setSorting] = useState([ + { id: "submitted_at", desc: true }, + ]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ asc: " ↑", desc: " ↓" }[ + header.column.getIsSorted() as string + ] ?? null} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 && ( + + + + {emptyMessage} + + + + )} + {table.getRowModel().rows.map((row) => ( + navigate(`/reviews/${row.original.id}`)} + hover + sx={{ cursor: "pointer" }} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} + const ReviewsQueue = () => { const { edits, newResources, editCounts } = useLoaderData<{ edits: ResourceEditQueueRow[]; newResources: ResourceEdit[]; editCounts: ResourceEditCount[]; }>(); - const navigate = useNavigate(); - const [sorting, setSorting] = useState([ - { id: "submitted_at", desc: true }, - ]); const pendingCountByResource = useMemo( () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), [editCounts], ); - const data: RowData[] = useMemo( - () => [ - ...edits.map((edit) => ({ + const editData: EditRowData[] = useMemo( + () => + edits.map((edit) => ({ id: edit.id, - kind: "EDIT" as const, name: edit.name ?? null, resource_type: edit.resource_type, resourceLabel: @@ -149,88 +241,54 @@ const ReviewsQueue = () => { ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) : 0, })), - ...newResources.map((edit) => ({ + [edits, pendingCountByResource], + ); + + const newData: NewRowData[] = useMemo( + () => + newResources.map((edit) => ({ id: edit.id, - kind: "NEW" as const, name: edit.name ?? null, resource_type: edit.resource_type, - resourceLabel: "—", submitted_at: edit.submitted_at, - competingEdits: 0, })), - ], - [edits, newResources, pendingCountByResource], + [newResources], ); - const table = useReactTable({ - data, - columns, - state: { sorting }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - }); return ( -
- - Resource reviews - - - Proposed edits to existing PHLask resources and brand-new site - submissions, pending approval. - - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {{ asc: " ↑", desc: " ↓" }[ - header.column.getIsSorted() as string - ] ?? null} - - ))} - - ))} - - - {table.getRowModel().rows.length === 0 && ( - - - - No pending reviews. Nothing to do here right now. - - - - )} - {table.getRowModel().rows.map((row) => ( - navigate(`/reviews/${row.original.id}`)} - hover - sx={{ cursor: "pointer" }} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
-
+ +
+ + Resource reviews + + + Proposed edits to existing PHLask resources and brand-new site + submissions, pending approval. + +
+ +
+ + New site submissions + + +
+ +
+ + Edits to existing resources + + +
+
); }; From b1e77ce0e149a7bc5ad9fd2bee9c32e5cff7d184 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 21:40:56 -0400 Subject: [PATCH 04/11] biome formatting --- app/routes/authenticated/reviews/detail.tsx | 5 ++++- app/routes/authenticated/reviews/index.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index dd54db6..8f3257c 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -33,7 +33,10 @@ import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; import { userContext } from "~/context/user"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceChangeLogEntry, ResourceEdit } from "~/types/ResourceEdit"; +import type { + ResourceChangeLogEntry, + ResourceEdit, +} from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index a158234..7fbada9 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -65,7 +65,9 @@ type NewRowData = { submitted_at: string; }; -const submittedColumn = (): ColumnDef => ({ +const submittedColumn = < + T extends { submitted_at: string }, +>(): ColumnDef => ({ accessorKey: "submitted_at", header: "Submitted", cell: ({ row }) => From e4466d68c03bb4aa9ac0914b16420a918bda7c6e Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 18 Aug 2026 19:39:59 -0400 Subject: [PATCH 05/11] Enhance review process with new submitted date column - Added a submitted date column to the reviews index, improving visibility of edit timelines. - Refactored the ReviewQueueTable component for better data handling and presentation. - Updated type definitions for edit and new row data to enhance clarity and type safety. --- ...18000000_resource_edits_dedupe_pending.sql | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql diff --git a/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql b/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql new file mode 100644 index 0000000..ba38144 --- /dev/null +++ b/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql @@ -0,0 +1,116 @@ +-- ============================================================================ +-- Enforce at most one PENDING resource_edits row per mapped_resource. +-- +-- Previously, nothing stopped a second "save" on the same resource from +-- inserting a competing PENDING row instead of overwriting the first one +-- (resource_edits_insert_anon just allows INSERT; there was no uniqueness +-- constraint on mapped_resource). Reviewers were seeing e.g. "2 pending +-- edits" for a single resource when only one made sense. +-- +-- Fix, in two layers: +-- 1. A BEFORE INSERT trigger that, when a PENDING edit already exists for +-- the incoming row's mapped_resource, updates that existing row in +-- place (overwriting the proposed values, bumping submitted_by/at) and +-- skips the insert entirely. This makes "save" behave as an upsert for +-- any client, with no client-side changes required. +-- 2. A partial unique index on (mapped_resource) where PENDING, as a +-- backstop against a race between two concurrent inserts slipping past +-- the trigger (the second insert fails loudly instead of silently +-- creating a duplicate). +-- +-- Also cleans up any duplicates that already exist: for each resource with +-- multiple PENDING edits, keeps the most recently submitted one and deletes +-- the rest (pre-production data, no need to preserve them). +-- ============================================================================ + +begin; + +-- --------------------------------------------------------------------------- +-- Resolve existing duplicates before the unique index can be created. +-- --------------------------------------------------------------------------- +with ranked as ( + select id, + row_number() over ( + partition by mapped_resource + order by submitted_at desc, id desc + ) as rn + from public.resource_edits + where review_status = 'PENDING' and mapped_resource is not null +) +delete from public.resource_edits e +using ranked +where e.id = ranked.id and ranked.rn > 1; + +-- --------------------------------------------------------------------------- +-- Backstop: at most one PENDING edit per resource. +-- --------------------------------------------------------------------------- +create unique index resource_edits_one_pending_per_resource + on public.resource_edits (mapped_resource) + where review_status = 'PENDING' and mapped_resource is not null; + +-- --------------------------------------------------------------------------- +-- Make a second "save" overwrite the existing pending edit instead of +-- inserting a competitor. +-- --------------------------------------------------------------------------- +create or replace function public._resource_edits_dedupe_pending() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + existing_id bigint; +begin + if new.review_status = 'PENDING' and new.mapped_resource is not null then + select id into existing_id + from public.resource_edits + where mapped_resource = new.mapped_resource + and review_status = 'PENDING' + for update; + + if existing_id is not null then + update public.resource_edits set + submitted_by = new.submitted_by, + submitted_at = now(), + version = new.version, + date_created = new.date_created, + creator = new.creator, + last_modified = new.last_modified, + last_modifier = new.last_modifier, + source = new.source, + verification = new.verification, + resource_type = new.resource_type, + address = new.address, + city = new.city, + state = new.state, + zip_code = new.zip_code, + latitude = new.latitude, + longitude = new.longitude, + gp_id = new.gp_id, + images = new.images, + guidelines = new.guidelines, + description = new.description, + name = new.name, + status = new.status, + entry_type = new.entry_type, + hours = new.hours, + water = new.water, + food = new.food, + forage = new.forage, + bathroom = new.bathroom + where id = existing_id; + + return null; -- row already applied via UPDATE; skip the INSERT + end if; + end if; + + return new; +end; +$$; + +create trigger resource_edits_dedupe_pending + before insert on public.resource_edits + for each row + execute function public._resource_edits_dedupe_pending(); + +commit; From 3cf6179c0dcefc15dd2fae375cf90e1391802c1d Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Wed, 9 Sep 2026 13:19:22 -0400 Subject: [PATCH 06/11] Drop the pending-edit dedupe migration The migration enforced at most one PENDING edit per resource by overwriting the existing row on each new submission. That makes the "Pending edits" column this PR adds permanently show 1 -- the warning chip for competing edits can never fire. It also destroys data: three people reporting three different things about one resource leaves only the last, unrecoverable and unseen by any reviewer. Returning NULL from the BEFORE INSERT trigger makes INSERT ... RETURNING yield zero rows, which breaks any submitting client that reads back the row it just created. And because the trigger is SECURITY DEFINER, an anonymous submitter can overwrite another person's pending edit, bypassing RLS. Competing edits are a real problem, but deleting the losing submissions hides it rather than resolving it, and the underlying lost-update bug survives the migration anyway (an older snapshot approved after a newer one still reverts it). Handling that belongs in approve_edit, tracked separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuKg4c7DHw29iNM2BXn6B3 --- ...18000000_resource_edits_dedupe_pending.sql | 116 ------------------ 1 file changed, 116 deletions(-) delete mode 100644 supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql diff --git a/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql b/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql deleted file mode 100644 index ba38144..0000000 --- a/supabase/migrations/20260818000000_resource_edits_dedupe_pending.sql +++ /dev/null @@ -1,116 +0,0 @@ --- ============================================================================ --- Enforce at most one PENDING resource_edits row per mapped_resource. --- --- Previously, nothing stopped a second "save" on the same resource from --- inserting a competing PENDING row instead of overwriting the first one --- (resource_edits_insert_anon just allows INSERT; there was no uniqueness --- constraint on mapped_resource). Reviewers were seeing e.g. "2 pending --- edits" for a single resource when only one made sense. --- --- Fix, in two layers: --- 1. A BEFORE INSERT trigger that, when a PENDING edit already exists for --- the incoming row's mapped_resource, updates that existing row in --- place (overwriting the proposed values, bumping submitted_by/at) and --- skips the insert entirely. This makes "save" behave as an upsert for --- any client, with no client-side changes required. --- 2. A partial unique index on (mapped_resource) where PENDING, as a --- backstop against a race between two concurrent inserts slipping past --- the trigger (the second insert fails loudly instead of silently --- creating a duplicate). --- --- Also cleans up any duplicates that already exist: for each resource with --- multiple PENDING edits, keeps the most recently submitted one and deletes --- the rest (pre-production data, no need to preserve them). --- ============================================================================ - -begin; - --- --------------------------------------------------------------------------- --- Resolve existing duplicates before the unique index can be created. --- --------------------------------------------------------------------------- -with ranked as ( - select id, - row_number() over ( - partition by mapped_resource - order by submitted_at desc, id desc - ) as rn - from public.resource_edits - where review_status = 'PENDING' and mapped_resource is not null -) -delete from public.resource_edits e -using ranked -where e.id = ranked.id and ranked.rn > 1; - --- --------------------------------------------------------------------------- --- Backstop: at most one PENDING edit per resource. --- --------------------------------------------------------------------------- -create unique index resource_edits_one_pending_per_resource - on public.resource_edits (mapped_resource) - where review_status = 'PENDING' and mapped_resource is not null; - --- --------------------------------------------------------------------------- --- Make a second "save" overwrite the existing pending edit instead of --- inserting a competitor. --- --------------------------------------------------------------------------- -create or replace function public._resource_edits_dedupe_pending() -returns trigger -language plpgsql -security definer -set search_path = public -as $$ -declare - existing_id bigint; -begin - if new.review_status = 'PENDING' and new.mapped_resource is not null then - select id into existing_id - from public.resource_edits - where mapped_resource = new.mapped_resource - and review_status = 'PENDING' - for update; - - if existing_id is not null then - update public.resource_edits set - submitted_by = new.submitted_by, - submitted_at = now(), - version = new.version, - date_created = new.date_created, - creator = new.creator, - last_modified = new.last_modified, - last_modifier = new.last_modifier, - source = new.source, - verification = new.verification, - resource_type = new.resource_type, - address = new.address, - city = new.city, - state = new.state, - zip_code = new.zip_code, - latitude = new.latitude, - longitude = new.longitude, - gp_id = new.gp_id, - images = new.images, - guidelines = new.guidelines, - description = new.description, - name = new.name, - status = new.status, - entry_type = new.entry_type, - hours = new.hours, - water = new.water, - food = new.food, - forage = new.forage, - bathroom = new.bathroom - where id = existing_id; - - return null; -- row already applied via UPDATE; skip the INSERT - end if; - end if; - - return new; -end; -$$; - -create trigger resource_edits_dedupe_pending - before insert on public.resource_edits - for each row - execute function public._resource_edits_dedupe_pending(); - -commit; From 46590a9cd157be3ed7758c16788d9daed72edf8c Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Wed, 9 Sep 2026 13:19:31 -0400 Subject: [PATCH 07/11] Report only real submitters on the dashboard, and count via the DB Top submitters bucketed every unattributed edit under "Unknown", which then dominated the leaderboard: on current data it reads "Unknown 9" and says nothing. Skip null submitted_by instead, so the existing empty state renders when there is genuinely nothing to attribute. creator is not a substitute -- it is a mirrored ResourceEntry field carried in the proposed snapshot, so it describes who created the resource (values are ingest sources like 'phlask_app'), not who submitted the edit. Renamed the loader field creator -> submitter, which is what it has actually held since this PR. totalEdits/pendingCount now come from count queries with head: true rather than counting fetched rows, and the loader comment no longer claims the change log avoids scanning resource_edits -- it is a plain view over that table, so it reads the same rows, just narrower columns. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuKg4c7DHw29iNM2BXn6B3 --- app/api/resource-edits/methods.ts | 23 +++++++++++++-- app/routes/authenticated/dashboard.tsx | 39 +++++++++++++++----------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index 266dccf..c05eff2 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -66,13 +66,12 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceChangeLogEntry[]; }, - /** Decided edit history for a single resource, e.g. for the review - * detail page's audit trail. Backed by `resource_change_log`. */ getChangeLogForResource: async (resourceId: number) => { const { data, error } = await client .from("resource_change_log") .select("*") - .eq("resource_id", resourceId); + .eq("resource_id", resourceId) + .order("reviewed_at", { ascending: false }); if (error) { throw error; @@ -80,6 +79,24 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceChangeLogEntry[]; }, + + getCounts: async () => { + const [total, pending] = await Promise.all([ + table.select("id", { count: "exact", head: true }), + table + .select("id", { count: "exact", head: true }) + .eq("review_status", "PENDING"), + ]); + + if (total.error) { + throw total.error; + } + if (pending.error) { + throw pending.error; + } + + return { total: total.count ?? 0, pending: pending.count ?? 0 }; + }, getList: async (params: { review_status?: EditReviewStatus } = {}) => { let query = table.select("*").order("submitted_at", { ascending: false }); diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index d90430f..3dc1ce2 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -30,11 +30,8 @@ export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); const editAPI = getResourceEditAPI(client); - // Every `resource_edits` row is either still PENDING (covered by the two - // queue views) or has been decided (covered by `resource_change_log`), so - // this trio fully reconstructs the stats below without ever scanning the - // full `resource_edits` table (with all its wide proposed-value columns). - const [pendingEdits, newResources, changeLog] = await Promise.all([ + const [counts, pendingEdits, newResources, changeLog] = await Promise.all([ + editAPI.getCounts(), editAPI.getEditsQueue(), editAPI.getNewResourcesQueue(), editAPI.getChangeLog(), @@ -46,9 +43,18 @@ export const loader: LoaderFunction = async ({ request }) => { const approverCounts = new Map(); const outstandingByType = new Map(); + const countSubmitter = (submittedBy: string | null) => { + if (!submittedBy) { + return; + } + submitterCounts.set( + submittedBy, + (submitterCounts.get(submittedBy) ?? 0) + 1, + ); + }; + for (const edit of pending) { - const submitter = edit.submitted_by || "Unknown"; - submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); + countSubmitter(edit.submitted_by); outstandingByType.set( edit.resource_type, (outstandingByType.get(edit.resource_type) ?? 0) + 1, @@ -56,8 +62,7 @@ export const loader: LoaderFunction = async ({ request }) => { } for (const entry of changeLog) { - const submitter = entry.submitted_by || "Unknown"; - submitterCounts.set(submitter, (submitterCounts.get(submitter) ?? 0) + 1); + countSubmitter(entry.submitted_by); if (entry.review_status === "APPROVED" && entry.reviewed_by) { approverCounts.set( @@ -70,7 +75,7 @@ export const loader: LoaderFunction = async ({ request }) => { const topSubmitters = [...submitterCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) - .map(([creator, count]) => ({ creator, count })); + .map(([submitter, count]) => ({ submitter, count })); const topApprovers = [...approverCounts.entries()] .sort((a, b) => b[1] - a[1]) @@ -83,8 +88,8 @@ export const loader: LoaderFunction = async ({ request }) => { })); return { - totalEdits: pending.length + changeLog.length, - pendingCount: pending.length, + totalEdits: counts.total, + pendingCount: counts.pending, topSubmitters, topApprovers, outstanding, @@ -94,7 +99,7 @@ export const loader: LoaderFunction = async ({ request }) => { type LoaderData = { totalEdits: number; pendingCount: number; - topSubmitters: { creator: string; count: number }[]; + topSubmitters: { submitter: string; count: number }[]; topApprovers: { reviewer: string; count: number }[]; outstanding: { type: string; count: number }[]; }; @@ -219,15 +224,15 @@ const Dashboard = () => { {topSubmitters.length === 0 ? ( - No submissions yet. + No submitter attribution recorded yet. ) : ( - {topSubmitters.map(({ creator, count }) => ( - - {creator} + {topSubmitters.map(({ submitter, count }) => ( + + {submitter} {count} From 0f802090432e7d8c6cc37cfc95d1f62f2b2273b6 Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Wed, 9 Sep 2026 13:19:39 -0400 Subject: [PATCH 08/11] Order the change log explicitly and harden the review loaders getChangeLogForResource relied on the view's own ORDER BY surviving an outer filtered query, which Postgres does not guarantee -- the change history could render in arbitrary order. Order it explicitly. The review detail loader fetched the resource and the change log serially, and unlike the resource fetch the change log was not guarded, so a view error took down the whole review page. Both are supporting context for the diff, so run them together and let each fall back to empty. ResourceEditQueueRow now declares mapped_resource as non-null: the view filters on `is not null` and inner-joins resources, so the narrower type is the accurate one. That removes the unreachable `: 0` branch behind the competing-edits count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuKg4c7DHw29iNM2BXn6B3 --- app/routes/authenticated/reviews/detail.tsx | 12 +++++------- app/routes/authenticated/reviews/index.tsx | 6 ++---- app/types/ResourceEdit.ts | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 8f3257c..38a0e55 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -178,14 +178,12 @@ export const loader: LoaderFunction = async ({ request, params }) => { let resource: ResourceEntry | null = null; let changeLog: ResourceChangeLogEntry[] = []; if (edit.mapped_resource !== null) { - try { - const resourceAPI = getResourceEntryAPI(client); - resource = await resourceAPI.getById(edit.mapped_resource); - } catch { - resource = null; - } + const resourceAPI = getResourceEntryAPI(client); - changeLog = await editAPI.getChangeLogForResource(edit.mapped_resource); + [resource, changeLog] = await Promise.all([ + resourceAPI.getById(edit.mapped_resource).catch(() => null), + editAPI.getChangeLogForResource(edit.mapped_resource).catch(() => []), + ]); } return { edit, resource, changeLog }; diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 7fbada9..18d397a 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -110,7 +110,7 @@ const editColumns: ColumnDef[] = [ color="warning" /> ) : ( - row.original.competingEdits || "—" + row.original.competingEdits ), }, submittedColumn(), @@ -239,9 +239,7 @@ const ReviewsQueue = () => { edit.current_resource?.address || `Resource #${edit.mapped_resource}`, submitted_at: edit.submitted_at, - competingEdits: edit.mapped_resource - ? (pendingCountByResource.get(edit.mapped_resource) ?? 1) - : 0, + competingEdits: pendingCountByResource.get(edit.mapped_resource) ?? 1, })), [edits, pendingCountByResource], ); diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts index 79fe672..1b71b30 100644 --- a/app/types/ResourceEdit.ts +++ b/app/types/ResourceEdit.ts @@ -27,8 +27,8 @@ export type ResourceEdit = ResourceEntry & { review_notes: string | null; }; -/** A row from `resource_edits_queue`: a pending edit joined to its target resource. */ export type ResourceEditQueueRow = ResourceEdit & { + mapped_resource: number; current_resource: ResourceEntry & { id: number }; }; From 925f8915b6f843e3adf96ef8aef16e8968801415 Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Wed, 9 Sep 2026 13:19:49 -0400 Subject: [PATCH 09/11] Populate resource_edits.submitted_by from the request JWT Nothing was ever setting this column: submitting clients do not send it and the admin dashboard only reads edits, never creates them. Every row arrives with submitted_by = null, which is why the dashboard has no submitters to attribute and the detail page cannot say who proposed a change. A BEFORE INSERT trigger fills it from request.jwt.claims (email, then sub) when it arrives blank, so attribution starts working with no client change. A value the client sent is never overwritten, malformed claims are caught so they cannot block a submission, and the trigger returns NEW so INSERT ... RETURNING is unaffected. Anonymous submissions still land with submitted_by = null -- an anon JWT carries no identity to record -- so attributing those needs the submitting client to send something. Includes the matching rollback, per the supabase/rollback convention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QuKg4c7DHw29iNM2BXn6B3 --- ...0_resource_edits_submitter_attribution.sql | 62 +++++++++++++++++++ ...ource_edits_submitter_attribution.down.sql | 9 +++ 2 files changed, 71 insertions(+) create mode 100644 supabase/migrations/20260908120000_resource_edits_submitter_attribution.sql create mode 100644 supabase/rollback/20260908120000_resource_edits_submitter_attribution.down.sql diff --git a/supabase/migrations/20260908120000_resource_edits_submitter_attribution.sql b/supabase/migrations/20260908120000_resource_edits_submitter_attribution.sql new file mode 100644 index 0000000..a9eb571 --- /dev/null +++ b/supabase/migrations/20260908120000_resource_edits_submitter_attribution.sql @@ -0,0 +1,62 @@ +-- ============================================================================ +-- Populate `resource_edits.submitted_by` from the request's JWT. +-- +-- Nothing was ever setting this column: submitting clients don't send it, and +-- the admin dashboard only reads edits, never creates them. The result is that +-- every row arrives with submitted_by = null, so the dashboard's "Top +-- submitters" panel has nothing to attribute and the review detail page can't +-- say who proposed a change. +-- +-- `creator` is not a substitute — it's a mirrored ResourceEntry field carried +-- along in the proposed snapshot, so it describes who created the *resource* +-- (values are ingest sources like 'phlask_app'), not who submitted the edit. +-- +-- This fills the column from the JWT on insert whenever the submitter is +-- signed in, without requiring any client change. Anonymous submissions still +-- land with submitted_by = null — there is genuinely no identity to record — +-- so attributing those requires the submitting client to send something. +-- ============================================================================ + +begin; + +create or replace function public._resource_edits_set_submitter() +returns trigger +language plpgsql +as $$ +declare + claims jsonb; +begin + -- Only fill a blank; never overwrite a value the client sent deliberately. + if new.submitted_by is not null and new.submitted_by <> '' then + return new; + end if; + + -- Unset (e.g. a direct psql insert) or unparseable claims are not an error + -- here; the column just stays null. + begin + claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb; + exception when others then + claims := null; + end; + + -- nullif per-claim, not around the coalesce: an empty-string `email` must + -- fall through to `sub` rather than swallowing it. + new.submitted_by := coalesce( + nullif(claims ->> 'email', ''), + nullif(claims ->> 'sub', '') + ); + + return new; +end; +$$; + +-- BEFORE INSERT so the value is stored on the row itself, and so a plain +-- `insert` still returns the row (no RETURNING surprises for clients). +drop trigger if exists resource_edits_set_submitter on public.resource_edits; + +create trigger resource_edits_set_submitter + before insert on public.resource_edits + for each row + execute function public._resource_edits_set_submitter(); + +commit; diff --git a/supabase/rollback/20260908120000_resource_edits_submitter_attribution.down.sql b/supabase/rollback/20260908120000_resource_edits_submitter_attribution.down.sql new file mode 100644 index 0000000..0a48aef --- /dev/null +++ b/supabase/rollback/20260908120000_resource_edits_submitter_attribution.down.sql @@ -0,0 +1,9 @@ +-- Rollback for 20260908120000_resource_edits_submitter_attribution.sql +-- Leaves already-attributed rows alone; only stops new inserts being filled in. + +begin; + +drop trigger if exists resource_edits_set_submitter on public.resource_edits; +drop function if exists public._resource_edits_set_submitter(); + +commit; From 0d570a6d689fd808de75aa8a289bb9a30e81f17d Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Tue, 13 Oct 2026 18:06:34 -0400 Subject: [PATCH 10/11] Drop unused getList and key review rows by edit id Addresses review feedback on #45. getList on the resource-edits API has had no callers since the dashboard moved to the count and queue queries, so remove it. The review tables now key rows by the edit's own id instead of TanStack's default array index, so row identity stays stable when the loader revalidates with different data. Co-Authored-By: Claude Opus 5.5 --- app/api/resource-edits/methods.ts | 16 ---------------- app/routes/authenticated/reviews/index.tsx | 1 + 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index c05eff2..2a7a0bd 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,6 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { - EditReviewStatus, ResourceChangeLogEntry, ResourceEdit, ResourceEditCount, @@ -97,21 +96,6 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return { total: total.count ?? 0, pending: pending.count ?? 0 }; }, - getList: async (params: { review_status?: EditReviewStatus } = {}) => { - let query = table.select("*").order("submitted_at", { ascending: false }); - - if (params.review_status) { - query = query.eq("review_status", params.review_status); - } - - const { data, error } = await query; - - if (error) { - throw error; - } - - return (data ?? []) as ResourceEdit[]; - }, getById: async (id: number) => { const { data, error } = await table .select("*") diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 18d397a..e8f884d 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -156,6 +156,7 @@ function ReviewQueueTable({ const table = useReactTable({ data, columns, + getRowId: (row) => String(row.id), state: { sorting }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), From a54ec5e6f33c07464ba611baf4c4cf02902f9126 Mon Sep 17 00:00:00 2001 From: Anilkumar3494 Date: Tue, 13 Oct 2026 18:06:35 -0400 Subject: [PATCH 11/11] Align resource types with phlask-map and fix food review crash phlask-map changed food.organization_type from an array to a single value in January 2026, but the dashboard still typed it as an array and rendered it in an . Every food edit submitted from the current map stores a string, so opening its review page threw "value.map is not a function". Organization type is now a single select, with a reader that still accepts the old array shape. food.tags, which the map's food form sets, is added to the type and carried through the form so saving an edit no longer drops it. The enums now match the map and the stored data: VESSEL replaces PITCHER (never present in the data), and HAS_FOUNTAIN and NONOPERATIONAL are added. The existing array rows in resources were normalized separately on 2026-10-13. Co-Authored-By: Claude Opus 5.5 --- app/routes/authenticated/reviews/detail.tsx | 42 +++++++++++++++------ app/types/ResourceEntry.ts | 15 ++++++-- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 38a0e55..ccc3124 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -62,7 +62,7 @@ const DISPENSER_TYPES: DispenserType[] = [ "SINK", "JUG", "SODA_MACHINE", - "PITCHER", + "VESSEL", "WATER_COOLER", ]; const WATER_TAGS: WaterTag[] = [ @@ -96,6 +96,7 @@ const BATHROOM_TAGS: BathroomTag[] = [ "GENDER_NEUTRAL", "CHANGING_TABLE", "SINGLE_OCCUPANCY", + "HAS_FOUNTAIN", "FAMILY", ]; @@ -125,14 +126,22 @@ type EditableValues = { food: { food_type: FoodType[]; distribution_type: DistributionType[]; - organization_type: OrganizationType[]; + organization_type: OrganizationType | ""; organization_name: string; organization_url: string; + tags: string[]; }; forage: { forage_type: ForageType[]; tags: ForageTag[] }; bathroom: { tags: BathroomTag[] }; }; +// phlask-map stores organization_type as a single value, but rows written +// before its January 2026 schema change hold an array — accept either. +const toOrganizationType = (value: unknown): OrganizationType | "" => { + const single = Array.isArray(value) ? value[0] : value; + return typeof single === "string" ? (single as OrganizationType) : ""; +}; + const toEditableValues = (edit: ResourceEdit): EditableValues => ({ name: edit.name ?? "", resource_type: edit.resource_type, @@ -152,9 +161,11 @@ const toEditableValues = (edit: ResourceEdit): EditableValues => ({ food: { food_type: edit.food?.food_type ?? [], distribution_type: edit.food?.distribution_type ?? [], - organization_type: edit.food?.organization_type ?? [], + organization_type: toOrganizationType(edit.food?.organization_type), organization_name: edit.food?.organization_name ?? "", organization_url: edit.food?.organization_url ?? "", + // Not editable here, but carried through so saving doesn't drop it. + tags: edit.food?.tags ?? [], }, forage: { forage_type: edit.forage?.forage_type ?? [], @@ -311,6 +322,7 @@ const ReviewDetail = () => { values.resource_type === "FOOD" ? { ...values.food, + organization_type: values.food.organization_type || undefined, organization_name: values.food.organization_name || undefined, organization_url: values.food.organization_url || undefined, } @@ -715,17 +727,25 @@ const ReviewDetail = () => { )} /> - setFood("organization_type", val)} - renderInput={(params) => ( - - )} - /> + onChange={(e) => + setFood( + "organization_type", + e.target.value as OrganizationType, + ) + } + > + {ORGANIZATION_TYPES.map((type) => ( + + {type} + + ))} +