From fe9a21892d5159a9e16c8f57724194a52374e717 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 19:32:21 -0400 Subject: [PATCH 1/4] Migrate reviews to resource_edits table Points the app at resource_edits (approve/reject/rollback RPCs, new-resource queue, review-queue views) instead of the throwaway resource_revisions table, and adds the missing UPDATE policy for authenticated reviewers editing PENDING rows before approval. Co-authored-by: Cursor --- app/api/resource-edits/methods.ts | 114 +++++++++++++++ app/api/resource-revisions/methods.ts | 69 --------- app/routes/authenticated/dashboard.tsx | 81 +++++++---- app/routes/authenticated/reviews/detail.tsx | 135 ++++++++++-------- app/routes/authenticated/reviews/index.tsx | 97 +++++++------ app/types/ResourceEdit.ts | 33 +++++ app/types/ResourceRevision.ts | 29 ---- app/utils/chipColors.tsx | 4 +- docs/next-up.md | 102 +++++++++++++ ...728000000_resource_edits_update_policy.sql | 21 +++ 10 files changed, 464 insertions(+), 221 deletions(-) create mode 100644 app/api/resource-edits/methods.ts delete mode 100644 app/api/resource-revisions/methods.ts create mode 100644 app/types/ResourceEdit.ts delete mode 100644 app/types/ResourceRevision.ts create mode 100644 docs/next-up.md create mode 100644 supabase/migrations/20260728000000_resource_edits_update_policy.sql diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts new file mode 100644 index 0000000..aa84d4d --- /dev/null +++ b/app/api/resource-edits/methods.ts @@ -0,0 +1,114 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { ResourceEntry } from "~/types/ResourceEntry"; +import type { + EditReviewStatus, + ResourceEdit, + ResourceEditQueueRow, +} from "~/types/ResourceEdit"; + +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 a703e6d..0000000 --- a/app/api/resource-revisions/methods.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { - ResourceRevision, - RevisionStatus, -} from "~/types/ResourceRevision"; - -const TABLE_NAME = "resource_revisions"; - -export const getResourceRevisionAPI = (client: SupabaseClient) => { - const table = client.from(TABLE_NAME); - - return { - getList: async (params: { status?: RevisionStatus } = {}) => { - let query = table.select("*").order("date_created", { ascending: false }); - - if (params.status) { - query = query.eq("status", params.status); - } - - const { data, error } = await query; - - if (error) { - throw error; - } - - return (data ?? []) as ResourceRevision[]; - }, - 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: RevisionStatus) => { - 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 845c394..754fb01 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.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,8 +135,13 @@ const StatCard = ({ ); const Dashboard = () => { - const { totalRevisions, pendingCount, topSubmitters, outstanding } = - useLoaderData(); + const { + totalEdits, + pendingCount, + topSubmitters, + topApprovers, + outstanding, + } = useLoaderData(); return ( @@ -143,7 +162,7 @@ const Dashboard = () => { /> } /> @@ -214,18 +233,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 d6c7acf..669a6cf 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -29,9 +29,10 @@ 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 { authMiddleware } from "~/middleware/auth"; +import { userContext } from "~/context/user"; import type { BathroomTag, DispenserType, @@ -45,7 +46,7 @@ import type { ResourceType, WaterTag, } from "~/types/ResourceEntry"; -import type { ResourceRevision } from "~/types/ResourceRevision"; +import type { ResourceEdit } from "~/types/ResourceEdit"; import { statusChipColor } from "~/utils/chipColors"; export const middleware = [authMiddleware]; @@ -129,35 +130,35 @@ type EditableValues = { bathroom: { tags: BathroomTag[] }; }; -const toEditableValues = (revision: ResourceRevision): 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(String(revision.mapped_resources)); - } catch { - resource = null; + if (edit.mapped_resource !== null) { + try { + const resourceAPI = getResourceEntryAPI(client); + resource = await resourceAPI.getById(String(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: ResourceRevision; + 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.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_resources}) - 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 3c13900..8563dad 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -19,10 +19,10 @@ import { } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; +import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getDatabaseClient } from "~/api/client.server"; -import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; import { authMiddleware } from "~/middleware/auth"; -import type { ResourceRevision } 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_resources))]; - 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 = ResourceRevision & { 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: ResourceRevision[]; - 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_resources] || - `Resource #${revision.mapped_resources}`, + 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 cd79fa6..0000000 --- a/app/types/ResourceRevision.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ResourceEntry } from "~/types/ResourceEntry"; - -/** - * Review status of a `resource_revisions` row. Note this table reuses the - * `status` column for review state rather than the resource's operational - * status (`ResourceStatus`) — the two are unrelated despite the shared - * column name. - */ -export type RevisionStatus = "PENDING" | "APPROVED" | "REJECTED"; - -/** - * A proposed edit to an existing resource, staged in `resource_revisions`. - * - * Shape mirrors `ResourceEntry` (it's a full proposed snapshot of the - * resource) plus: - * - `mapped_resources`: FK to `resources.id` — the resource this revision - * proposes changes to. Every revision maps to an existing resource; this - * table has no concept of a brand-new, not-yet-existing resource. - * - `mapped_resource`: a second, unconstrained (no FK) int column that - * exists on the table but isn't used by this feature. - * - `status`: review state (`PENDING` / `APPROVED` / `REJECTED`), not the - * resource's operational status. - */ -export type ResourceRevision = Omit & { - id: number; - mapped_resource: number; - mapped_resources: number; - status: RevisionStatus; -}; diff --git a/app/utils/chipColors.tsx b/app/utils/chipColors.tsx index 9509c68..ff99767 100644 --- a/app/utils/chipColors.tsx +++ b/app/utils/chipColors.tsx @@ -2,7 +2,7 @@ import { LocalDining, Park, WaterDrop, Wc } from "@mui/icons-material"; import type { ChipProps } from "@mui/material"; import type { ReactElement } from "react"; import type { ResourceType } from "~/types/ResourceEntry"; -import type { RevisionStatus } from "~/types/ResourceRevision"; +import type { EditReviewStatus } from "~/types/ResourceEdit"; export const resourceTypeChipColor = ( type: ResourceType | string, @@ -72,7 +72,7 @@ export const resourceTypeChipIcon = ( }; export const statusChipColor = ( - status: RevisionStatus | string, + status: EditReviewStatus | string, ): ChipProps["color"] => { switch (status) { case "PENDING": diff --git a/docs/next-up.md b/docs/next-up.md new file mode 100644 index 0000000..c74743d --- /dev/null +++ b/docs/next-up.md @@ -0,0 +1,102 @@ +# Next Up + +Working list of the next pieces to tackle. Companion to `implementation-progress.md` +(build log) and `resource-review-flow-scoping.md` (original flow scoping doc, now +partly superseded by the `resource_edits` migration — see item 1). + +## 1. Verify `resources` / `resource_revisions` schemas match — DONE (2026-07-28) + +Checked live Supabase (anon key, REST probing — no service-role/DDL access available, +same constraint noted in `implementation-progress.md`). + +- `resources`: 952 rows. Columns: `address, bathroom, city, creator, date_created, + description, entry_type, food, forage, gp_id, guidelines, hours, id, images, + last_modified, last_modifier, latitude, longitude, name, resource_type, source, + state, status, verification, version, water, zip_code`. +- `resource_revisions`: 4 rows (no longer empty — some test/seed rows exist now). + Exact same columns as `resources`, **plus** `mapped_resource` and + `mapped_resources` (the FK'd one is `mapped_resources`, plural — confirmed still + true, still confusingly named). `status` still does double duty (review status + here vs. operational status on `resources`). Matches `app/types/ResourceRevision.ts` + and the discovery notes in `implementation-progress.md` exactly — no drift. +- `resource_edits` (from the `20260714231214_resource_editing.sql` migration): table + exists live and its full column list matches the migration file exactly (0 rows). + **Not yet wired to any app code or UI** — only `resource_revisions` is used by + `app/api/resource-revisions/methods.ts` and the `/reviews` routes today. + +Open question this raises: **two parallel staging tables now exist** +(`resource_revisions`, actively used by the UI; `resource_edits`, migrated in #24 but +unused). Need a decision on which one this app builds forward on before doing more +work here — items 3-5 below assume that's settled first. + +**Decision (2026-07-28): migrate to `resource_edits`.** It already ships the +approve/reject/rollback RPCs, the new-resource (`mapped_resource IS NULL`) path, and +ready-made review-queue views (`resource_edits_queue`, `new_resources_queue`, +`resource_edit_counts`, `resource_change_log`) — most of items 2 and 3's plumbing +already exists in Supabase and just needs wiring into the app. `resource_revisions` +gets left alone (per the migration's own comment) but the app stops reading from it. + +## 2. Point approved edits to go live in `resources` — DONE (2026-07-28) + +`/reviews` and `/reviews/:id` are now fully migrated off `resource_revisions` onto +`resource_edits`: +- `app/api/resource-edits/methods.ts` (new) wraps `resource_edits_queue`, + `new_resources_queue`, and the `approve_edit`/`reject_edit` RPCs. +- `/reviews` loader now merges the edit queue and new-resource queue into one table, + with a "Kind" (New/Edit) column. +- `/reviews/:id` approve/reject now call the real RPCs (capturing the reviewer's email + from `context.get(userContext)`), so approving an edit actually applies it to + `resources` — the change is now live for `phlask-map` to read. Rejecting a + brand-new-site submission correctly skips the "resource not found" diff warning. +- `app/routes/authenticated/dashboard.tsx` also migrated; "Top approvers" is unlocked + since `resource_edits.reviewed_by` exists (it never existed on `resource_revisions`). +- `app/api/resource-revisions/` and `app/types/ResourceRevision.ts` deleted — nothing + references them anymore. +- **Needs a manual step**: `supabase/migrations/20260728000000_resource_edits_update_policy.sql` + adds the UPDATE policy `resource_edits` was missing for `authenticated` (PENDING rows + only) — without it, "Save changes" on the detail page silently no-ops under RLS. This + repo only has the anon key here, so it hasn't been applied to the live DB yet — run it + via the Supabase SQL editor or CLI with an owner/service-role credential. +- `phlask-map` (sibling repo) reads live map data straight from `resources` already, so + no phlask-map-side change needed for data to *appear* — still worth confirming + phlask-map has no caching layer that would delay visibility. + +## 3. New resource vs. editing existing resource — flow logic + +Review-side queues for both cases now exist (see item 2 — `/reviews` shows both, with +brand-new submissions using `new_resources_queue` and a null `mapped_resource`). +Remaining piece: +- The submission-side distinction in `phlask-map` (currently "Add Resource" inserts + directly into `resources`; "Suggest Edit" is still a `// TODO` per the scoping doc) — + that repo needs to start writing brand-new submissions into `resource_edits` with + `mapped_resource = null` instead of inserting directly into `resources`, for this + queue to actually receive anything. + +## 4. Duplicate-detection / confidence rating for new resources + +When a "new resource" submission comes in, compare it against existing points of the +same `resource_type` already in `resources` (and pending ones in the revisions/edits +table) to flag likely duplicates before a reviewer wastes time on it. Needs: +- A similarity scoring approach — geographic proximity (lat/lng distance) combined + with field similarity (name, address, resource_type) — producing a confidence score, + not just a boolean. +- A threshold/UI treatment for "likely duplicate, needs a merge decision" vs. "clean + new submission." +- The scoping doc sketches this as `match_confidence` on a submission row (§4.1, §5.1) + but that was written against the doc's *proposed* schema, not the real + `resource_revisions`/`resource_edits` tables — needs re-grounding against whichever + table item 1 settles on. + +## 5. Map view + street view during resource validation + +Add a map view (and Street View / imagery) to the `/reviews/:id` detail page so a +reviewer can visually confirm a submission's location without leaving the dashboard. +Needs a maps provider decision (Google Maps JS API for Street View compatibility is +the likely fit, given `gp_id` — Google Places ID — already exists on the resource +schema) and an API key. + +## Sequencing note + +Items 2-5 all sit downstream of the item-1 decision (which staging table survives: +`resource_revisions` or `resource_edits`). Worth resolving that first rather than +building item 2 or 4 twice. 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; From b386366916d8b52fdff7513d39e6a5b696c5cc0a Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 19:44:00 -0400 Subject: [PATCH 2/4] Fix Biome formatting/import-order issues from merge biome check flagged import ordering and formatting drift introduced while resolving the merge conflicts with main. Co-authored-by: Cursor --- app/api/resource-edits/methods.ts | 2 +- app/routes/authenticated/dashboard.tsx | 9 ++------- app/routes/authenticated/reviews/detail.tsx | 12 ++++++------ app/routes/authenticated/reviews/index.tsx | 2 +- app/utils/chipColors.tsx | 2 +- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index aa84d4d..d76a1dc 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,10 +1,10 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import type { ResourceEntry } from "~/types/ResourceEntry"; import type { EditReviewStatus, ResourceEdit, ResourceEditQueueRow, } from "~/types/ResourceEdit"; +import type { ResourceEntry } from "~/types/ResourceEntry"; const TABLE_NAME = "resource_edits"; diff --git a/app/routes/authenticated/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 754fb01..4323b0c 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -135,13 +135,8 @@ const StatCard = ({ ); const Dashboard = () => { - const { - totalEdits, - pendingCount, - topSubmitters, - topApprovers, - outstanding, - } = useLoaderData(); + const { totalEdits, pendingCount, topSubmitters, topApprovers, outstanding } = + useLoaderData(); return ( diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx index ef54ade..5669d93 100644 --- a/app/routes/authenticated/reviews/detail.tsx +++ b/app/routes/authenticated/reviews/detail.tsx @@ -31,8 +31,9 @@ import { import { getDatabaseClient } from "~/api/client.server"; import { getResourceEditAPI } from "~/api/resource-edits/methods"; import { getResourceEntryAPI } from "~/api/resources/methods"; -import { authMiddleware } from "~/middleware/auth"; import { userContext } from "~/context/user"; +import { authMiddleware } from "~/middleware/auth"; +import type { ResourceEdit } from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -46,7 +47,6 @@ import type { ResourceType, WaterTag, } from "~/types/ResourceEntry"; -import type { ResourceEdit } from "~/types/ResourceEdit"; import { statusChipColor } from "~/utils/chipColors"; export const middleware = [authMiddleware]; @@ -368,15 +368,15 @@ const ReviewDetail = () => { {isNew && ( - This is a brand-new site submission — there is no existing resource - to compare against. + This is a brand-new site submission — there is no existing resource to + compare against. )} {!isNew && !resource && ( - The resource this edit targets (#{edit.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. )} diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index 8563dad..cb32608 100644 --- a/app/routes/authenticated/reviews/index.tsx +++ b/app/routes/authenticated/reviews/index.tsx @@ -19,8 +19,8 @@ import { } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import { type LoaderFunction, useLoaderData, useNavigate } from "react-router"; -import { getResourceEditAPI } from "~/api/resource-edits/methods"; 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 { diff --git a/app/utils/chipColors.tsx b/app/utils/chipColors.tsx index ff99767..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 { ResourceType } from "~/types/ResourceEntry"; import type { EditReviewStatus } from "~/types/ResourceEdit"; +import type { ResourceType } from "~/types/ResourceEntry"; export const resourceTypeChipColor = ( type: ResourceType | string, From 5779b83fc4d84b6c087a5c90618e7463a3400e2d Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 20:04:16 -0400 Subject: [PATCH 3/4] Add pre-bundling configuration for @mui/icons-material in Vite This change optimizes dependency handling for the @mui/icons-material package by including it in the optimizeDeps configuration. This prevents file handle exhaustion during development, particularly in environments with additional overhead like OneDrive-synced folders. --- vite.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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"], + }, }); From eae5169290d91a1cbfbb7127f4e74a3907bd8f89 Mon Sep 17 00:00:00 2001 From: tayloraj10 Date: Tue, 11 Aug 2026 20:11:17 -0400 Subject: [PATCH 4/4] Update .gitignore to include local notes and remove next-up.md file This commit adds a new entry to .gitignore for local notes and deletes the now-unnecessary next-up.md file, which contained a working list of tasks. --- .gitignore | 3 ++ docs/next-up.md | 102 ------------------------------------------------ 2 files changed, 3 insertions(+), 102 deletions(-) delete mode 100644 docs/next-up.md 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/docs/next-up.md b/docs/next-up.md deleted file mode 100644 index c74743d..0000000 --- a/docs/next-up.md +++ /dev/null @@ -1,102 +0,0 @@ -# Next Up - -Working list of the next pieces to tackle. Companion to `implementation-progress.md` -(build log) and `resource-review-flow-scoping.md` (original flow scoping doc, now -partly superseded by the `resource_edits` migration — see item 1). - -## 1. Verify `resources` / `resource_revisions` schemas match — DONE (2026-07-28) - -Checked live Supabase (anon key, REST probing — no service-role/DDL access available, -same constraint noted in `implementation-progress.md`). - -- `resources`: 952 rows. Columns: `address, bathroom, city, creator, date_created, - description, entry_type, food, forage, gp_id, guidelines, hours, id, images, - last_modified, last_modifier, latitude, longitude, name, resource_type, source, - state, status, verification, version, water, zip_code`. -- `resource_revisions`: 4 rows (no longer empty — some test/seed rows exist now). - Exact same columns as `resources`, **plus** `mapped_resource` and - `mapped_resources` (the FK'd one is `mapped_resources`, plural — confirmed still - true, still confusingly named). `status` still does double duty (review status - here vs. operational status on `resources`). Matches `app/types/ResourceRevision.ts` - and the discovery notes in `implementation-progress.md` exactly — no drift. -- `resource_edits` (from the `20260714231214_resource_editing.sql` migration): table - exists live and its full column list matches the migration file exactly (0 rows). - **Not yet wired to any app code or UI** — only `resource_revisions` is used by - `app/api/resource-revisions/methods.ts` and the `/reviews` routes today. - -Open question this raises: **two parallel staging tables now exist** -(`resource_revisions`, actively used by the UI; `resource_edits`, migrated in #24 but -unused). Need a decision on which one this app builds forward on before doing more -work here — items 3-5 below assume that's settled first. - -**Decision (2026-07-28): migrate to `resource_edits`.** It already ships the -approve/reject/rollback RPCs, the new-resource (`mapped_resource IS NULL`) path, and -ready-made review-queue views (`resource_edits_queue`, `new_resources_queue`, -`resource_edit_counts`, `resource_change_log`) — most of items 2 and 3's plumbing -already exists in Supabase and just needs wiring into the app. `resource_revisions` -gets left alone (per the migration's own comment) but the app stops reading from it. - -## 2. Point approved edits to go live in `resources` — DONE (2026-07-28) - -`/reviews` and `/reviews/:id` are now fully migrated off `resource_revisions` onto -`resource_edits`: -- `app/api/resource-edits/methods.ts` (new) wraps `resource_edits_queue`, - `new_resources_queue`, and the `approve_edit`/`reject_edit` RPCs. -- `/reviews` loader now merges the edit queue and new-resource queue into one table, - with a "Kind" (New/Edit) column. -- `/reviews/:id` approve/reject now call the real RPCs (capturing the reviewer's email - from `context.get(userContext)`), so approving an edit actually applies it to - `resources` — the change is now live for `phlask-map` to read. Rejecting a - brand-new-site submission correctly skips the "resource not found" diff warning. -- `app/routes/authenticated/dashboard.tsx` also migrated; "Top approvers" is unlocked - since `resource_edits.reviewed_by` exists (it never existed on `resource_revisions`). -- `app/api/resource-revisions/` and `app/types/ResourceRevision.ts` deleted — nothing - references them anymore. -- **Needs a manual step**: `supabase/migrations/20260728000000_resource_edits_update_policy.sql` - adds the UPDATE policy `resource_edits` was missing for `authenticated` (PENDING rows - only) — without it, "Save changes" on the detail page silently no-ops under RLS. This - repo only has the anon key here, so it hasn't been applied to the live DB yet — run it - via the Supabase SQL editor or CLI with an owner/service-role credential. -- `phlask-map` (sibling repo) reads live map data straight from `resources` already, so - no phlask-map-side change needed for data to *appear* — still worth confirming - phlask-map has no caching layer that would delay visibility. - -## 3. New resource vs. editing existing resource — flow logic - -Review-side queues for both cases now exist (see item 2 — `/reviews` shows both, with -brand-new submissions using `new_resources_queue` and a null `mapped_resource`). -Remaining piece: -- The submission-side distinction in `phlask-map` (currently "Add Resource" inserts - directly into `resources`; "Suggest Edit" is still a `// TODO` per the scoping doc) — - that repo needs to start writing brand-new submissions into `resource_edits` with - `mapped_resource = null` instead of inserting directly into `resources`, for this - queue to actually receive anything. - -## 4. Duplicate-detection / confidence rating for new resources - -When a "new resource" submission comes in, compare it against existing points of the -same `resource_type` already in `resources` (and pending ones in the revisions/edits -table) to flag likely duplicates before a reviewer wastes time on it. Needs: -- A similarity scoring approach — geographic proximity (lat/lng distance) combined - with field similarity (name, address, resource_type) — producing a confidence score, - not just a boolean. -- A threshold/UI treatment for "likely duplicate, needs a merge decision" vs. "clean - new submission." -- The scoping doc sketches this as `match_confidence` on a submission row (§4.1, §5.1) - but that was written against the doc's *proposed* schema, not the real - `resource_revisions`/`resource_edits` tables — needs re-grounding against whichever - table item 1 settles on. - -## 5. Map view + street view during resource validation - -Add a map view (and Street View / imagery) to the `/reviews/:id` detail page so a -reviewer can visually confirm a submission's location without leaving the dashboard. -Needs a maps provider decision (Google Maps JS API for Street View compatibility is -the likely fit, given `gp_id` — Google Places ID — already exists on the resource -schema) and an API key. - -## Sequencing note - -Items 2-5 all sit downstream of the item-1 decision (which staging table survives: -`resource_revisions` or `resource_edits`). Worth resolving that first rather than -building item 2 or 4 twice.