diff --git a/app/api/resource-edits/methods.ts b/app/api/resource-edits/methods.ts index d76a1dc..2a7a0bd 100644 --- a/app/api/resource-edits/methods.ts +++ b/app/api/resource-edits/methods.ts @@ -1,7 +1,8 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { - EditReviewStatus, + ResourceChangeLogEntry, ResourceEdit, + ResourceEditCount, ResourceEditQueueRow, } from "~/types/ResourceEdit"; import type { ResourceEntry } from "~/types/ResourceEntry"; @@ -38,20 +39,62 @@ export const getResourceEditAPI = (client: SupabaseClient) => { return (data ?? []) as ResourceEdit[]; }, - getList: async (params: { review_status?: EditReviewStatus } = {}) => { - let query = table.select("*").order("submitted_at", { ascending: false }); + /** 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 (params.review_status) { - query = query.eq("review_status", params.review_status); + if (error) { + throw error; } - const { data, error } = await query; + 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 ResourceEdit[]; + return (data ?? []) as ResourceChangeLogEntry[]; + }, + getChangeLogForResource: async (resourceId: number) => { + const { data, error } = await client + .from("resource_change_log") + .select("*") + .eq("resource_id", resourceId) + .order("reviewed_at", { ascending: false }); + + if (error) { + throw error; + } + + 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 }; }, getById: async (id: number) => { const { data, error } = await table 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/dashboard.tsx b/app/routes/authenticated/dashboard.tsx index 4323b0c..3dc1ce2 100644 --- a/app/routes/authenticated/dashboard.tsx +++ b/app/routes/authenticated/dashboard.tsx @@ -29,29 +29,45 @@ 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(); + + const [counts, pendingEdits, newResources, changeLog] = await Promise.all([ + editAPI.getCounts(), + 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); - - if (edit.review_status === "PENDING") { - pendingCount += 1; - outstandingByType.set( - edit.resource_type, - (outstandingByType.get(edit.resource_type) ?? 0) + 1, - ); + const countSubmitter = (submittedBy: string | null) => { + if (!submittedBy) { + return; } + submitterCounts.set( + submittedBy, + (submitterCounts.get(submittedBy) ?? 0) + 1, + ); + }; + + for (const edit of pending) { + countSubmitter(edit.submitted_by); + outstandingByType.set( + edit.resource_type, + (outstandingByType.get(edit.resource_type) ?? 0) + 1, + ); + } + + for (const entry of changeLog) { + countSubmitter(entry.submitted_by); - 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, ); } } @@ -59,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]) @@ -72,8 +88,8 @@ export const loader: LoaderFunction = async ({ request }) => { })); return { - totalEdits: edits.length, - pendingCount, + totalEdits: counts.total, + pendingCount: counts.pending, topSubmitters, topApprovers, outstanding, @@ -83,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 }[]; }; @@ -208,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} 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..ccc3124 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, @@ -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 { ResourceEdit } from "~/types/ResourceEdit"; +import type { + ResourceChangeLogEntry, + ResourceEdit, +} from "~/types/ResourceEdit"; import type { BathroomTag, DispenserType, @@ -59,7 +62,7 @@ const DISPENSER_TYPES: DispenserType[] = [ "SINK", "JUG", "SODA_MACHINE", - "PITCHER", + "VESSEL", "WATER_COOLER", ]; const WATER_TAGS: WaterTag[] = [ @@ -93,6 +96,7 @@ const BATHROOM_TAGS: BathroomTag[] = [ "GENDER_NEUTRAL", "CHANGING_TABLE", "SINGLE_OCCUPANCY", + "HAS_FOUNTAIN", "FAMILY", ]; @@ -122,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, @@ -149,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 ?? [], @@ -173,16 +187,17 @@ 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); - resource = await resourceAPI.getById(edit.mapped_resource); - } catch { - resource = null; - } + const resourceAPI = getResourceEntryAPI(client); + + [resource, changeLog] = await Promise.all([ + resourceAPI.getById(edit.mapped_resource).catch(() => null), + editAPI.getChangeLogForResource(edit.mapped_resource).catch(() => []), + ]); } - return { edit, resource }; + return { edit, resource, changeLog }; }; export const action: ActionFunction = async ({ request, params, context }) => { @@ -238,9 +253,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 }>(); @@ -282,7 +298,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, @@ -300,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, } @@ -380,6 +403,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 ?? "—"} + + + ))} + +
+
+ + )} + @@ -665,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} + + ))} + { {isPending && ( - + + +
diff --git a/app/routes/authenticated/reviews/index.tsx b/app/routes/authenticated/reviews/index.tsx index cb32608..e8f884d 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, @@ -22,7 +23,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,36 +39,45 @@ 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 = { +type EditRowData = { id: number; - kind: "EDIT" | "NEW"; name: string | null; 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[] = [ - { - accessorKey: "kind", - header: "Kind", - cell: ({ row }) => ( - - ), - }, +type NewRowData = { + id: number; + name: string | null; + resource_type: ResourceEdit["resource_type"]; + submitted_at: string; +}; + +const submittedColumn = < + T extends { submitted_at: string }, +>(): 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", @@ -86,31 +100,139 @@ const columns: ColumnDef[] = [ header: "Existing resource", }, { - accessorKey: "submitted_at", - header: "Submitted", + accessorKey: "competingEdits", + header: "Pending edits", cell: ({ row }) => - new Date(row.original.submitted_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), + row.original.competingEdits > 1 ? ( + + ) : ( + row.original.competingEdits + ), }, + submittedColumn(), ]; -const ReviewsQueue = () => { - const { edits, newResources } = useLoaderData<{ - edits: ResourceEditQueueRow[]; - newResources: ResourceEdit[]; - }>(); +const newColumns: ColumnDef[] = [ + { + 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 data: RowData[] = useMemo( - () => [ - ...edits.map((edit) => ({ + const table = useReactTable({ + data, + columns, + getRowId: (row) => String(row.id), + 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 pendingCountByResource = useMemo( + () => new Map(editCounts.map((c) => [c.resource_id, c.pending_count])), + [editCounts], + ); + + const editData: EditRowData[] = useMemo( + () => + edits.map((edit) => ({ id: edit.id, - kind: "EDIT" as const, name: edit.name ?? null, resource_type: edit.resource_type, resourceLabel: @@ -118,88 +240,56 @@ const ReviewsQueue = () => { edit.current_resource?.address || `Resource #${edit.mapped_resource}`, submitted_at: edit.submitted_at, + competingEdits: pendingCountByResource.get(edit.mapped_resource) ?? 1, })), - ...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, })), - ], - [edits, newResources], + [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 + + +
+
); }; diff --git a/app/types/ResourceEdit.ts b/app/types/ResourceEdit.ts index 934482a..1b71b30 100644 --- a/app/types/ResourceEdit.ts +++ b/app/types/ResourceEdit.ts @@ -27,7 +27,32 @@ 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 }; }; + +/** + * 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; +}; diff --git a/app/types/ResourceEntry.ts b/app/types/ResourceEntry.ts index f59f305..9584ca1 100644 --- a/app/types/ResourceEntry.ts +++ b/app/types/ResourceEntry.ts @@ -22,7 +22,8 @@ export type ResourceStatus = | "OPERATIONAL" | "TEMPORARILY_CLOSED" | "PERMANENTLY_CLOSED" - | "HIDDEN"; + | "HIDDEN" + | "NONOPERATIONAL"; /** * Entry permission type for a resource. @@ -38,7 +39,7 @@ export type DispenserType = | "SINK" | "JUG" | "SODA_MACHINE" - | "PITCHER" + | "VESSEL" | "WATER_COOLER"; /** @@ -87,6 +88,7 @@ export type BathroomTag = | "GENDER_NEUTRAL" | "CHANGING_TABLE" | "SINGLE_OCCUPANCY" + | "HAS_FOUNTAIN" | "FAMILY"; /** @@ -155,12 +157,17 @@ export type FoodInfo = { food_type: FoodType[]; /** The permitted ways to access the food. Must have at least one entry. */ distribution_type: DistributionType[]; - /** The type of organization providing this food. Must have at least one entry. */ - organization_type: OrganizationType[]; + /** + * The type of organization providing this food. A single value, matching + * phlask-map; rows predating its January 2026 schema change stored an array. + */ + organization_type?: OrganizationType; /** If available, the name of the organization providing the resource */ organization_name?: string; /** If available, a URL to more information about this food resource */ organization_url?: string; + /** Free-form "helpful info" tags set by the phlask-map food form. Can be empty. */ + tags?: string[]; }; /** 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;