diff --git a/.gitignore b/.gitignore index 639e566..066e01d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ # Amplify build output /.amplify-hosting/ + +# Local notes +docs/next-up.md diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts new file mode 100644 index 0000000..d76a1dc --- /dev/null +++ b/app/api/resource-edits/methods.ts @@ -0,0 +1,114 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { + EditReviewStatus, + ResourceEdit, + ResourceEditQueueRow, +} from "~/types/ResourceEdit"; +import type { ResourceEntry } from "~/types/ResourceEntry"; + +const TABLE_NAME = "resource_edits"; + +export const getResourceEditAPI = (client: SupabaseClient) => { + const table = client.from(TABLE_NAME); + + return { + /** All pending edits to existing resources, joined to the current resource for compare. */ + getEditsQueue: async () => { + const { data, error } = await client + .from("resource_edits_queue") + .select("*") + .order("submitted_at", { ascending: false }); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceEditQueueRow[]; + }, + /** All pending brand-new site submissions (no mapped_resource). */ + getNewResourcesQueue: async () => { + const { data, error } = await client + .from("new_resources_queue") + .select("*") + .order("submitted_at", { ascending: false }); + + if (error) { + throw error; + } + + return (data ?? []) as ResourceEdit[]; + }, + 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("*") + .eq("id", id) + .single(); + + if (error) { + throw error; + } + + return data; + }, + /** Partial update to the proposed values while an edit is PENDING. */ + updateFields: async (id: number, values: Partial) => { + const { data, error } = await table + .update(values) + .eq("id", id) + .select() + .single(); + + if (error) { + throw error; + } + + return data; + }, + /** Approves the edit via the `approve_edit` RPC — applies it to `resources` + * (updating the mapped resource, or inserting a new one) so the change + * goes live on the map. Returns the resulting `resources` row. */ + approve: async (id: number, reviewer: string) => { + const { data, error } = await client + .rpc("approve_edit", { p_edit_id: id, p_reviewer: reviewer }) + .single(); + + if (error) { + throw error; + } + + return data; + }, + reject: async (id: number, reviewer: string, notes?: string) => { + const { data, error } = await client + .rpc("reject_edit", { + p_edit_id: id, + p_reviewer: reviewer, + p_notes: notes ?? null, + }) + .single(); + + if (error) { + throw error; + } + + return data; + }, + }; +}; + +export default getResourceEditAPI; diff --git a/app/api/resource-revisions/methods.ts b/app/api/resource-revisions/methods.ts deleted file mode 100644 index 0596649..0000000 --- a/app/api/resource-revisions/methods.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { - ResourceEdit, - ResourceEditReviewStatus, -} from "~/types/ResourceRevision"; - -const TABLE_NAME = "resource_edits"; - -export const getResourceRevisionAPI = (client: SupabaseClient) => { - const table = client.from(TABLE_NAME); - - return { - getList: async (params: { status?: ResourceEditReviewStatus } = {}) => { - let query = table.select("*").order("date_created", { ascending: false }); - - if (params.status) { - query = query.eq("review_status", params.status); - } - - const { data, error } = await query; - - if (error) { - throw error; - } - - return (data ?? []) as ResourceEdit[]; - }, - getById: async (id: number) => { - const { data, error } = await table - .select("*") - .eq("id", id) - .single(); - - if (error) { - throw error; - } - - return data; - }, - updateStatus: async (id: number, status: ResourceEditReviewStatus) => { - const { data, error } = await table - .update({ status }) - .eq("id", id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - }, - updateFields: async (id: number, values: Partial) => { - const { data, error } = await table - .update(values) - .eq("id", id) - .select() - .single(); - - if (error) { - throw error; - } - - return data; - }, - }; -}; - -export default getResourceRevisionAPI; diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 2e8c899..4323b0c 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -9,13 +9,12 @@ import { TableCell, TableContainer, TableRow, - Tooltip, Typography, } from "@mui/material"; import type { LoaderFunction } from "react-router"; import { Link, useLoaderData } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { authMiddleware } from "~/middleware/auth"; import type { ResourceType } from "~/types/ResourceEntry"; import { @@ -29,22 +28,30 @@ const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); - const revisionAPI = getResourceRevisionAPI(client); - const revisions = await revisionAPI.getList(); + const editAPI = getResourceEditAPI(client); + const edits = await editAPI.getList(); const submitterCounts = new Map(); + const approverCounts = new Map(); const outstandingByType = new Map(); let pendingCount = 0; - for (const revision of revisions) { - const creator = revision.creator || "Unknown"; + for (const edit of edits) { + const creator = edit.creator || "Unknown"; submitterCounts.set(creator, (submitterCounts.get(creator) ?? 0) + 1); - if (revision.review_status === "PENDING") { + if (edit.review_status === "PENDING") { pendingCount += 1; outstandingByType.set( - revision.resource_type, - (outstandingByType.get(revision.resource_type) ?? 0) + 1, + edit.resource_type, + (outstandingByType.get(edit.resource_type) ?? 0) + 1, + ); + } + + if (edit.review_status === "APPROVED" && edit.reviewed_by) { + approverCounts.set( + edit.reviewed_by, + (approverCounts.get(edit.reviewed_by) ?? 0) + 1, ); } } @@ -54,23 +61,30 @@ export const loader: LoaderFunction = async ({ request }) => { .slice(0, 5) .map(([creator, count]) => ({ creator, count })); + const topApprovers = [...approverCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([reviewer, count]) => ({ reviewer, count })); + const outstanding = RESOURCE_TYPES.map((type) => ({ type, count: outstandingByType.get(type) ?? 0, })); return { - totalRevisions: revisions.length, + totalEdits: edits.length, pendingCount, topSubmitters, + topApprovers, outstanding, }; }; type LoaderData = { - totalRevisions: number; + totalEdits: number; pendingCount: number; topSubmitters: { creator: string; count: number }[]; + topApprovers: { reviewer: string; count: number }[]; outstanding: { type: string; count: number }[]; }; @@ -121,7 +135,7 @@ const StatCard = ({ ); const Dashboard = () => { - const { totalRevisions, pendingCount, topSubmitters, outstanding } = + const { totalEdits, pendingCount, topSubmitters, topApprovers, outstanding } = useLoaderData(); return ( @@ -143,7 +157,7 @@ const Dashboard = () => { /> } /> @@ -214,18 +228,30 @@ const Dashboard = () => { )} - - - - Top approvers - - - - Not trackable yet — approvals don't record who approved them. + + + Top approvers + {topApprovers.length === 0 ? ( + + No approvals yet. + + ) : ( + + + + {topApprovers.map(({ reviewer, count }) => ( + + {reviewer} + + {count} + + + ))} + +
+
+ )}
diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index 3a02b85..5669d93 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -29,9 +29,11 @@ import { useLoaderData, } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +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 { BathroomTag, DispenserType, @@ -45,7 +47,6 @@ import type { ResourceType, WaterTag, } from "~/types/ResourceEntry"; -import type { ResourceEdit } from "~/types/ResourceRevision"; import { statusChipColor } from "~/utils/chipColors"; export const middleware = [authMiddleware]; @@ -129,35 +130,35 @@ type EditableValues = { bathroom: { tags: BathroomTag[] }; }; -const toEditableValues = (revision: ResourceEdit): EditableValues => ({ - name: revision.name ?? "", - resource_type: revision.resource_type, - entry_type: revision.entry_type ?? "", - address: revision.address ?? "", - city: revision.city ?? "", - state: revision.state ?? "", - zip_code: revision.zip_code ?? "", - latitude: revision.latitude, - longitude: revision.longitude, - description: revision.description ?? "", - guidelines: revision.guidelines ?? "", +const toEditableValues = (edit: ResourceEdit): EditableValues => ({ + name: edit.name ?? "", + resource_type: edit.resource_type, + entry_type: edit.entry_type ?? "", + address: edit.address ?? "", + city: edit.city ?? "", + state: edit.state ?? "", + zip_code: edit.zip_code ?? "", + latitude: edit.latitude, + longitude: edit.longitude, + description: edit.description ?? "", + guidelines: edit.guidelines ?? "", water: { - dispenser_type: revision.water?.dispenser_type ?? [], - tags: revision.water?.tags ?? [], + dispenser_type: edit.water?.dispenser_type ?? [], + tags: edit.water?.tags ?? [], }, food: { - food_type: revision.food?.food_type ?? [], - distribution_type: revision.food?.distribution_type ?? [], - organization_type: revision.food?.organization_type ?? [], - organization_name: revision.food?.organization_name ?? "", - organization_url: revision.food?.organization_url ?? "", + food_type: edit.food?.food_type ?? [], + distribution_type: edit.food?.distribution_type ?? [], + organization_type: edit.food?.organization_type ?? [], + organization_name: edit.food?.organization_name ?? "", + organization_url: edit.food?.organization_url ?? "", }, forage: { - forage_type: revision.forage?.forage_type ?? [], - tags: revision.forage?.tags ?? [], + forage_type: edit.forage?.forage_type ?? [], + tags: edit.forage?.tags ?? [], }, bathroom: { - tags: revision.bathroom?.tags ?? [], + tags: edit.bathroom?.tags ?? [], }, }); @@ -168,28 +169,31 @@ export const loader: LoaderFunction = async ({ request, params }) => { } const { client } = getDatabaseClient(request); - const revisionAPI = getResourceRevisionAPI(client); - const revision = await revisionAPI.getById(id); + const editAPI = getResourceEditAPI(client); + const edit = await editAPI.getById(id); let resource: ResourceEntry | null = null; - try { - const resourceAPI = getResourceEntryAPI(client); - resource = await resourceAPI.getById(revision.mapped_resource); - } catch { - resource = null; + if (edit.mapped_resource !== null) { + try { + const resourceAPI = getResourceEntryAPI(client); + resource = await resourceAPI.getById(edit.mapped_resource); + } catch { + resource = null; + } } - return { revision, resource }; + return { edit, resource }; }; -export const action: ActionFunction = async ({ request, params }) => { +export const action: ActionFunction = async ({ request, params, context }) => { const id = Number(params.id); if (!Number.isInteger(id)) { throw data("Not found", { status: 404 }); } const { client, headers } = getDatabaseClient(request); - const revisionAPI = getResourceRevisionAPI(client); + const editAPI = getResourceEditAPI(client); + const reviewer = context.get(userContext)?.email ?? "unknown"; const contentType = request.headers.get("content-type") || ""; if (contentType.includes("application/json")) { @@ -200,8 +204,8 @@ export const action: ActionFunction = async ({ request, params }) => { } try { - const updated = await revisionAPI.updateFields(id, body.values); - return data({ message: "Changes saved.", ok: true, revision: updated }); + const updated = await editAPI.updateFields(id, body.values); + return data({ message: "Changes saved.", ok: true, edit: updated }); } catch { return data({ message: "Failed to save changes." }, { status: 400 }); } @@ -214,31 +218,43 @@ export const action: ActionFunction = async ({ request, params }) => { return data({ message: "Unknown action" }, { status: 400 }); } - await revisionAPI.updateStatus( - id, - intent === "approve" ? "APPROVED" : "REJECTED", - ); + try { + if (intent === "approve") { + await editAPI.approve(id, reviewer); + } else { + await editAPI.reject(id, reviewer); + } + } catch (error) { + return data( + { + message: + error instanceof Error ? error.message : "Failed to review edit.", + }, + { status: 400 }, + ); + } return redirect("/reviews", { headers }); }; const ReviewDetail = () => { - const { revision, resource } = useLoaderData<{ - revision: ResourceEdit; + const { edit, resource } = useLoaderData<{ + edit: ResourceEdit; resource: ResourceEntry | null; }>(); const actionData = useActionData<{ message?: string }>(); const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); const [values, setValues] = useState(() => - toEditableValues(revision), + toEditableValues(edit), ); useEffect(() => { - setValues(toEditableValues(revision)); - }, [revision]); + setValues(toEditableValues(edit)); + }, [edit]); - const isPending = revision.review_status === "PENDING"; + const isNew = edit.mapped_resource === null; + const isPending = edit.review_status === "PENDING"; const isSaving = fetcher.state !== "idle"; const setField = ( @@ -267,7 +283,7 @@ const ReviewDetail = () => { ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); const handleSave = () => { - const payload: Partial = { + const payload: Partial = { name: values.name || null, resource_type: values.resource_type, entry_type: values.entry_type || null, @@ -335,25 +351,32 @@ const ReviewDetail = () => {
Submitted{" "} - {new Date(revision.date_created).toLocaleString(undefined, { + {new Date(edit.submitted_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", })} - {revision.creator ? ` by ${revision.creator}` : ""} + {edit.creator ? ` by ${edit.creator}` : ""} - {!resource && ( + {isNew && ( + + This is a brand-new site submission — there is no existing resource to + compare against. + + )} + + {!isNew && !resource && ( - The resource this revision targets (#{revision.mapped_resource}) could - not be found — it may have been deleted. + The resource this edit targets (#{edit.mapped_resource}) could not be + found — it may have been deleted. )} @@ -553,13 +576,13 @@ const ReviewDetail = () => { Hours {formatValue(resource?.hours)} - {formatValue(revision.hours)} + {formatValue(edit.hours)} Images {formatValue(resource?.images)} - {formatValue(revision.images)} + {formatValue(edit.images)} diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 5a58d49..cb32608 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -20,9 +20,9 @@ import { import { useMemo, useState } from "react"; import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; import { getDatabaseClient } from "~/api/client.server"; -import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceEdit } from "~/types/ResourceRevision"; +import type { ResourceEdit, ResourceEditQueueRow } from "~/types/ResourceEdit"; import { resourceTypeChipColor, resourceTypeChipIcon, @@ -32,36 +32,38 @@ export const middleware = [authMiddleware]; export const loader: LoaderFunction = async ({ request }) => { const { client } = getDatabaseClient(request); - const revisionAPI = getResourceRevisionAPI(client); - const revisions = await revisionAPI.getList({ status: "PENDING" }); + const editAPI = getResourceEditAPI(client); - const resourceIds = [...new Set(revisions.map((r) => r.mapped_resource))]; - const resourceNames = new Map(); - - if (resourceIds.length > 0) { - const { data, error } = await client - .from("resources") - .select("id, name, address") - .in("id", resourceIds); - - if (error) { - throw error; - } - - for (const resource of data ?? []) { - resourceNames.set(resource.id, resource.name || resource.address || null); - } - } + const [edits, newResources] = await Promise.all([ + editAPI.getEditsQueue(), + editAPI.getNewResourcesQueue(), + ]); - return { - revisions, - resourceNames: Object.fromEntries(resourceNames), - }; + return { edits, newResources }; }; -type RowData = ResourceEdit & { resourceLabel: string }; +type RowData = { + id: number; + kind: "EDIT" | "NEW"; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + resourceLabel: string; + submitted_at: string; +}; const columns: ColumnDef[] = [ + { + accessorKey: "kind", + header: "Kind", + cell: ({ row }) => ( + + ), + }, { accessorKey: "name", header: "Proposed name", @@ -84,10 +86,10 @@ const columns: ColumnDef[] = [ header: "Existing resource", }, { - accessorKey: "date_created", + accessorKey: "submitted_at", header: "Submitted", cell: ({ row }) => - new Date(row.original.date_created).toLocaleString(undefined, { + new Date(row.original.submitted_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", }), @@ -95,24 +97,38 @@ const columns: ColumnDef[] = [ ]; const ReviewsQueue = () => { - const { revisions, resourceNames } = useLoaderData<{ - revisions: ResourceEdit[]; - resourceNames: Record; + const { edits, newResources } = useLoaderData<{ + edits: ResourceEditQueueRow[]; + newResources: ResourceEdit[]; }>(); const navigate = useNavigate(); const [sorting, setSorting] = useState([ - { id: "date_created", desc: true }, + { id: "submitted_at", desc: true }, ]); const data: RowData[] = useMemo( - () => - revisions.map((revision) => ({ - ...revision, + () => [ + ...edits.map((edit) => ({ + id: edit.id, + kind: "EDIT" as const, + name: edit.name ?? null, + resource_type: edit.resource_type, resourceLabel: - resourceNames[revision.mapped_resource] || - `Resource #${revision.mapped_resource}`, + edit.current_resource?.name || + edit.current_resource?.address || + `Resource #${edit.mapped_resource}`, + submitted_at: edit.submitted_at, + })), + ...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, })), - [revisions, resourceNames], + ], + [edits, newResources], ); const table = useReactTable({ @@ -126,10 +142,11 @@ const ReviewsQueue = () => { return (
- Resource edit reviews + Resource reviews - Proposed edits to existing PHLask resources, pending approval. + Proposed edits to existing PHLask resources and brand-new site + submissions, pending approval. diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts new file mode 100644 index 0000000..934482a --- /dev/null +++ b/app/types/ResourceEdit.ts @@ -0,0 +1,33 @@ +import type { ResourceEntry } from "~/types/ResourceEntry"; + +/** + * Review status of a `resource_edits` row. + */ +export type EditReviewStatus = "PENDING" | "APPROVED" | "REJECTED"; + +/** + * A crowdsourced submission staged in `resource_edits` — either a proposed + * edit to an existing resource (`mapped_resource` set) or a brand-new site + * submission (`mapped_resource` is null). + * + * Shape mirrors `ResourceEntry` (full proposed snapshot) plus the review + * lifecycle columns. Unlike `resource_revisions`, `status` here is a purely + * mirrored *operational* status field (`ResourceEntry["status"]`) — review + * state lives in the dedicated `review_status` column instead, so there's no + * column-overload ambiguity. + */ +export type ResourceEdit = ResourceEntry & { + id: number; + mapped_resource: number | null; + review_status: EditReviewStatus; + submitted_by: string | null; + submitted_at: string; + reviewed_by: string | null; + reviewed_at: string | null; + review_notes: string | null; +}; + +/** A row from `resource_edits_queue`: a pending edit joined to its target resource. */ +export type ResourceEditQueueRow = ResourceEdit & { + current_resource: ResourceEntry & { id: number }; +}; diff --git a/app/types/ResourceRevision.ts b/app/types/ResourceRevision.ts deleted file mode 100644 index 549aec1..0000000 --- a/app/types/ResourceRevision.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ResourceEntry } from "~/types/ResourceEntry"; - -export type ResourceEditReviewStatus = "PENDING" | "APPROVED" | "REJECTED"; - -export type ResourceEdit = { - id?: number; - mapped_resource: number; - review_status: ResourceEditReviewStatus; - submitted_by?: string; - submitted_at?: Date | string; - reviewed_by?: string; - reviewed_at?: Date | string; - review_notes?: string; -} & Omit; diff --git a/app/utils/chipColors.tsx b/app/utils/chipColors.tsx index 956b6e5..6c15edb 100644 --- a/app/utils/chipColors.tsx +++ b/app/utils/chipColors.tsx @@ -1,8 +1,8 @@ import { LocalDining, Park, WaterDrop, Wc } from "@mui/icons-material"; import type { ChipProps } from "@mui/material"; import type { ReactElement } from "react"; +import type { EditReviewStatus } from "~/types/ResourceEdit"; import type { ResourceType } from "~/types/ResourceEntry"; -import type { ResourceEditReviewStatus } from "~/types/ResourceRevision"; export const resourceTypeChipColor = ( type: ResourceType | string, @@ -72,7 +72,7 @@ export const resourceTypeChipIcon = ( }; export const statusChipColor = ( - status: ResourceEditReviewStatus | string, + status: EditReviewStatus | string, ): ChipProps["color"] => { switch (status) { case "PENDING": diff --git a/supabase/migrations/20260728000000_resource_edits_update_policy.sql b/supabase/migrations/20260728000000_resource_edits_update_policy.sql new file mode 100644 index 0000000..e1797d4 --- /dev/null +++ b/supabase/migrations/20260728000000_resource_edits_update_policy.sql @@ -0,0 +1,21 @@ +-- ============================================================================ +-- resource_edits was missing an UPDATE policy for authenticated reviewers. +-- The review UI lets a reviewer tweak the proposed values before approving +-- (previously worked against `resource_revisions`); without this, those +-- writes are silently dropped by RLS (0 rows affected, no error) once the +-- app is pointed at `resource_edits` instead. +-- +-- Scoped to PENDING rows only, and the check clause blocks flipping +-- review_status via this path (approve/reject must go through the RPCs, +-- which is where reviewed_by/reviewed_at get set). +-- ============================================================================ + +begin; + +create policy resource_edits_update_pending_auth + on public.resource_edits for update + to authenticated + using (review_status = 'PENDING') + with check (review_status = 'PENDING'); + +commit; diff --git a/vite.config.ts b/vite.config.ts index c4140c9..c9ec9a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,4 +7,11 @@ export default defineConfig({ server: { port: 5174, }, + optimizeDeps: { + // @mui/icons-material ships ~2000+ individual ESM files; without + // pre-bundling, Vite's dev-time loader opens them one by one on demand + // and can exhaust file handles (EMFILE), especially inside a + // OneDrive-synced folder where the sync engine adds extra overhead. + include: ["@mui/icons-material"], + }, });