From 05854818a01246670c6135404a1093a788b007a9 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Sat, 31 Oct 2026 17:28:05 +0500 Subject: [PATCH] Map admins: owners invite friends to co-manage maps Owners can invite players by name from the map workshop; invitees accept from their dashboard and then manage the map (details, locations, submission, refresh, contributions, appeals) through owner-delegated checks. Adds the map_admins table, invite/admin API endpoints, dashboard invite box, workshop admin management, and Russian translations. --- components/AdminInvites.tsx | 82 ++++++ components/Dashboard.tsx | 6 + components/DraftEditor.tsx | 12 + components/MapAdmins.tsx | 131 +++++++++ lib/api.ts | 261 ++++++++++++++++-- package.json | 1 + scripts/test-map-admins-sql.mjs | 25 ++ shared/database.ts | 38 +++ shared/locales/ru.json | 18 +- .../migrations/20261031120000_map_admins.sql | 23 ++ tests/sql/map-admins.sql | 24 ++ 11 files changed, 603 insertions(+), 18 deletions(-) create mode 100644 components/AdminInvites.tsx create mode 100644 components/MapAdmins.tsx create mode 100644 scripts/test-map-admins-sql.mjs create mode 100644 supabase/migrations/20261031120000_map_admins.sql create mode 100644 tests/sql/map-admins.sql diff --git a/components/AdminInvites.tsx b/components/AdminInvites.tsx new file mode 100644 index 0000000..ebf5cdc --- /dev/null +++ b/components/AdminInvites.tsx @@ -0,0 +1,82 @@ +"use client"; +import { useI18n } from "@/components/I18nProvider"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "./StartGame"; + +export type AdminInvite = { + id: string; + created_at: string; + mapName: string; + ownerName: string; +}; +export function AdminInvites({ onChange }: { onChange: () => void }) { + const { t } = useI18n(); + const [invites, setInvites] = useState([]); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const load = useCallback(async () => { + try { + setInvites((await api<{ invites: AdminInvite[] }>("admin-invites")).invites); + setError(""); + } catch (e) { + setError((e as Error).message); + } + }, []); + useEffect(() => { + load(); + const timer = setInterval(load, 10000); + return () => clearInterval(timer); + }, [load]); + async function respond(id: string, decision: "accept" | "decline") { + setBusy(true); + setError(""); + try { + await api(`admin-invites/${id}`, { decision }); + await load(); + onChange(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + if (!invites.length && !error) return null; + return ( +
+ {t("SHARED WITH YOU")} +

{t("Map admin invites")}

+ {invites.map((invite) => ( +
+ + {t("{owner} invited you to manage {map}.", { + owner: invite.ownerName, + map: invite.mapName, + })} + +
+ + +
+
+ ))} + {error && ( +

+ {t(error)} +

+ )} +
+ ); +} diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index e571f1e..d93d78c 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -11,7 +11,9 @@ import type { MapListing } from "@/lib/catalog"; import type { ProcessingJob } from "@/shared/processing-progress"; import { ProcessingProgress } from "./ProcessingProgress"; import { ImportMap } from "./MapTransfer"; +import { AdminInvites } from "./AdminInvites"; type Draft = MapListing & { + access?: "owner" | "admin"; upload_jobs: ProcessingJob | null; }; export function Dashboard() { @@ -128,6 +130,7 @@ export function Dashboard() { )} + {maps.map((m) => (
@@ -135,6 +138,9 @@ export function Dashboard() { {m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}

{m.name}

+ {m.access === "admin" && ( + {t("Map admin")} + )}{" "} {m.contribution_status ? t("Contribution: {status}", { diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx index 822078b..0f9517a 100644 --- a/components/DraftEditor.tsx +++ b/components/DraftEditor.tsx @@ -13,8 +13,10 @@ import { OwnerContributions } from "./OwnerContributions"; import Link from "next/link"; import { MapRefresh } from "./MapRefresh"; import { MapAppeal } from "./MapAppeal"; +import { MapAdmins } from "./MapAdmins"; import { ExportMap } from "./MapTransfer"; type Draft = { + access: "owner" | "admin"; imported_from_id: string | null; imported_owner_name: string | null; ownsPublishedParent: boolean; @@ -175,6 +177,13 @@ export function DraftEditor({ )}
{t(statusMessage(draft.state))} + {draft.access === "admin" && ( +

+ {t( + "You manage this map as an admin. The owner invited you to help run it.", + )} +

+ )} {!draft.imported_from_id && !draft.parent_dataset_id && draft.map && @@ -440,6 +449,9 @@ export function DraftEditor({ onChange={() => setVersion((v) => v + 1)} /> )} + {!draft.imported_from_id && !draft.parent_dataset_id && ( + + )} {(["uploading", "processing"].includes(draft.state) || (draft.parent_dataset_id && ["pending", "submitted"].includes( diff --git a/components/MapAdmins.tsx b/components/MapAdmins.tsx new file mode 100644 index 0000000..e1089cc --- /dev/null +++ b/components/MapAdmins.tsx @@ -0,0 +1,131 @@ +"use client"; +import { useI18n } from "@/components/I18nProvider"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "./StartGame"; + +export type MapAdmin = { + id: string; + user_id: string; + state: string; + created_at: string; + name: string; + username: string | null; +}; +export function MapAdmins({ id, access }: { id: string; access: string }) { + const { t } = useI18n(); + const [admins, setAdmins] = useState([]); + const [caller, setCaller] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const load = useCallback(async () => { + try { + const result = await api<{ admins: MapAdmin[]; caller: string }>( + `datasets/${id}/admins`, + ); + setAdmins(result.admins); + setCaller(result.caller); + setError(""); + } catch (e) { + setError((e as Error).message); + } + }, [id]); + useEffect(() => { + load(); + const timer = setInterval(load, 10000); + return () => clearInterval(timer); + }, [load]); + async function invite(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + setMessage(""); + try { + await api(`datasets/${id}/admins`, { name }); + setName(""); + setMessage( + "Invite sent. Your friend can accept it from their dashboard.", + ); + await load(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + async function remove(userId: string) { + setBusy(true); + setError(""); + try { + await api(`datasets/${id}/admins/${userId}`, undefined, "DELETE"); + await load(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + return ( +
+

{t("Map admins")}

+

+ {t( + "Invite friends to manage this map with you. They can edit details, choose locations, refresh the map and handle contributions after accepting your invite.", + )} +

+ {admins.map((a) => ( +
+ + {a.name} + {a.username ? @{a.username} : null}{" "} + + {a.state === "accepted" ? t("· admin") : t("· invite pending")} + + + {(access === "owner" || a.user_id === caller) && ( + + )} +
+ ))} + {access === "owner" && ( +
+ + +
+ )} + {error && ( +

+ {t(error)} +

+ )} + {message && ( +

+ {t(message)} +

+ )} +
+ ); +} diff --git a/lib/api.ts b/lib/api.ts index bb39b97..5992344 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -105,18 +105,31 @@ async function limit(key: string, count: number, seconds: number) { if (!allowed) throw new Error("Too many requests. Please wait and try again."); } -async function ownedDataset(request: NextRequest, id: string) { - const owner = await contributor(request); +async function managedDataset( + request: NextRequest, + id: string, + ownerOnly = false, +) { + const caller = await contributor(request); const dataset = required( + await admin().from("datasets").select("*").eq("id", uuid(id)).single(), + ); + const ownerId = dataset.owner_id; + if (!ownerId) throw new Error("Record not found"); + // Keep the real owner id: map RPCs prove management rights through it. + if (ownerId === caller) + return { ...dataset, owner_id: ownerId, access: "owner" as const, caller }; + if (ownerOnly) throw new ServiceError("Map owner permission required", 403); + required( await admin() - .from("datasets") - .select("*") - .eq("id", uuid(id)) - .eq("owner_id", owner) + .from("map_admins") + .select("id") + .eq("dataset_id", dataset.id) + .eq("user_id", caller) + .eq("state", "accepted") .single(), ); - // The ownership predicate above proves this is a current account's map. - return { ...dataset, owner_id: owner }; + return { ...dataset, owner_id: ownerId, access: "admin" as const, caller }; } async function session(request: NextRequest, id: string) { const record = required( @@ -420,7 +433,7 @@ export async function handle(request: NextRequest, path: string[]) { const body = z .object({ datasetId: idSchema }) .parse(await jsonBody(request)); - const d = await ownedDataset(request, body.datasetId); + const d = await managedDataset(request, body.datasetId, true); if (method === "POST" && path[1] === "sign") { if (d.state !== "uploading") throw new Error("Dataset no longer accepts uploads"); @@ -491,9 +504,35 @@ export async function handle(request: NextRequest, path: string[]) { ) .order("created_at", { ascending: false }), ); + const membership = required( + await client + .from("map_admins") + .select("dataset_id") + .eq("user_id", owner) + .eq("state", "accepted"), + ); + const managed = membership.length + ? required( + await client + .from("datasets") + .select( + "*, upload_jobs!dataset_id(id,state,progress,error,updated_at,lease_until,kind,source_dataset_id)", + ) + .in( + "id", + membership.map((m) => m.dataset_id), + ) + .is("parent_dataset_id", null) + .order("created_at", { ascending: false }), + ) + : []; + const all = [ + ...maps.map((m) => ({ ...m, access: "owner" as const })), + ...managed.map((m) => ({ ...m, access: "admin" as const })), + ].sort((a, b) => b.created_at.localeCompare(a.created_at)); return NextResponse.json({ maps: await Promise.all( - maps.map(async (m) => { + all.map(async (m) => { if (needsProcessing(m.upload_jobs)) processUploadAfterResponse(m.id); return { @@ -505,7 +544,114 @@ export async function handle(request: NextRequest, path: string[]) { ), }); } - const d = await ownedDataset(request, path[1]); + const d = await managedDataset(request, path[1]); + if (path[2] === "admins") { + if (d.parent_dataset_id) + throw new Error("Contributions cannot have map admins"); + if (method === "GET" && path.length === 3) { + const rows = required( + await client + .from("map_admins") + .select("id,user_id,state,created_at") + .eq("dataset_id", d.id) + .order("created_at"), + ); + const profiles = rows.length + ? required( + await client + .from("profiles") + .select("id,display_name,username") + .in( + "id", + rows.map((r) => r.user_id), + ), + ) + : []; + return NextResponse.json({ + admins: rows.map((r) => ({ + ...r, + name: + profiles.find((p) => p.id === r.user_id)?.display_name ?? + "Explorer", + username: + profiles.find((p) => p.id === r.user_id)?.username ?? null, + })), + access: d.access, + caller: d.caller, + }); + } + if (method === "POST" && path.length === 3) { + if (d.access !== "owner") + throw new ServiceError("Map owner permission required", 403); + const body = z + .object({ name: z.string().trim().min(1).max(80) }) + .parse(await jsonBody(request)); + await limit("map-admins:" + d.owner_id, 20, 3600); + const pattern = body.name + .replace(/[\\%_]/g, (c) => "\\" + c) + .replace(/[,()"]/g, ""); + const candidates = required( + await client + .from("profiles") + .select("id,display_name,username") + .or(`username.ilike.${pattern},display_name.ilike.${pattern}`) + .limit(5), + ); + if (!candidates.length) + throw new Error("No player found with that name"); + if (candidates.length > 1) + throw new Error( + "Several players use this name. Ask your friend for their username.", + ); + const target = candidates[0]; + if (target.id === d.owner_id) + throw new Error("This player already owns the map"); + const existing = await client + .from("map_admins") + .select("id,state") + .eq("dataset_id", d.id) + .eq("user_id", target.id) + .maybeSingle(); + checkServiceError(existing.error); + if (existing.data) + throw new Error( + existing.data.state === "accepted" + ? "This player is already a map admin" + : "This player already has a pending invite", + ); + const { count, error: countError } = await client + .from("map_admins") + .select("id", { count: "exact", head: true }) + .eq("dataset_id", d.id); + checkServiceError(countError); + if ((count ?? 0) >= 10) + throw new Error("This map already has 10 admins"); + const { error } = await client.from("map_admins").insert({ + dataset_id: d.id, + user_id: target.id, + invited_by: d.caller, + }); + if (error?.code === "23505") + throw new ServiceError("This player already has an invite", 409); + checkServiceError(error); + return NextResponse.json({ ok: true }); + } + if (method === "DELETE" && path.length === 4) { + const target = uuid(path[3]); + if (d.access !== "owner" && target !== d.caller) + throw new ServiceError("Map owner permission required", 403); + required( + await client + .from("map_admins") + .delete() + .eq("dataset_id", d.id) + .eq("user_id", target) + .select("id") + .single(), + ); + return NextResponse.json({ ok: true }); + } + } if (method === "GET" && path[2] === "export" && path.length === 3) { if ( d.imported_from_id || @@ -783,14 +929,13 @@ export async function handle(request: NextRequest, path: string[]) { return NextResponse.json({ ok: true }); } if (method === "POST" && path[2] === "publish-update") { - const owner = await contributor(request, true); - if (owner !== d.owner_id) - throw new ServiceError("Map owner permission required", 403); + // Browser session required; membership was verified by managedDataset. + await contributor(request, true); checkServiceError( ( await client.rpc("publish_owner_update", { p_dataset: d.id, - p_owner: owner, + p_owner: d.owner_id, }) ).error, ); @@ -865,7 +1010,7 @@ export async function handle(request: NextRequest, path: string[]) { ); return NextResponse.json(info); } - const parent = await ownedDataset(request, target); + const parent = await managedDataset(request, target); if (parent.parent_dataset_id) throw new Error("Choose the original map"); if (method === "GET" && path[2] === "inbox" && path.length === 3) { const rows = required( @@ -934,7 +1079,7 @@ export async function handle(request: NextRequest, path: string[]) { const { error } = await client.rpc("review_contribution", { p_dataset: target, p_contribution: child.id, - p_owner: owner, + p_owner: parent.owner_id, p_decision: body.decision, p_reason: body.reason ?? undefined, }); @@ -942,6 +1087,88 @@ export async function handle(request: NextRequest, path: string[]) { return NextResponse.json({ ok: true }); } } + if (path[0] === "admin-invites") { + const caller = await contributor(request, true); + if (method === "GET" && path.length === 1) { + const rows = required( + await client + .from("map_admins") + .select("id,created_at,dataset_id") + .eq("user_id", caller) + .eq("state", "pending") + .order("created_at"), + ); + const maps = rows.length + ? required( + await client + .from("datasets") + .select("id,name,owner_id") + .in( + "id", + rows.map((r) => r.dataset_id), + ), + ) + : []; + const profiles = maps.length + ? required( + await client + .from("profiles") + .select("id,display_name") + .in( + "id", + maps + .map((m) => m.owner_id) + .filter((id): id is string => id !== null), + ), + ) + : []; + return NextResponse.json({ + invites: rows.map((r) => { + const map = maps.find((m) => m.id === r.dataset_id); + return { + id: r.id, + created_at: r.created_at, + mapName: map?.name ?? "Map", + ownerName: + profiles.find((p) => p.id === map?.owner_id)?.display_name ?? + "Explorer", + }; + }), + }); + } + if (method === "POST" && path.length === 2) { + const body = z + .object({ decision: z.enum(["accept", "decline"]) }) + .parse(await jsonBody(request)); + if (body.decision === "accept") { + required( + await client + .from("map_admins") + .update({ + state: "accepted", + responded_at: new Date().toISOString(), + }) + .eq("id", uuid(path[1])) + .eq("user_id", caller) + .eq("state", "pending") + .select("id") + .single(), + ); + } else { + required( + await client + .from("map_admins") + .delete() + .eq("id", uuid(path[1])) + .eq("user_id", caller) + .eq("state", "pending") + .select("id") + .single(), + ); + } + return NextResponse.json({ ok: true }); + } + } if (path[0] === "tokens") { const owner = await contributor(request, true); if (method === "GET") diff --git a/package.json b/package.json index 129cb3f..bf032aa 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bots": "node scripts/moderator-bots.mjs", "test": "tsx --test tests/*.test.ts", "test:leaderboard": "node scripts/test-leaderboard-sql.mjs", + "test:map-admins": "node scripts/test-map-admins-sql.mjs", "test:moderator-applications": "node scripts/test-moderator-applications-sql.mjs", "test:multiplayer": "node scripts/test-multiplayer-sql.mjs", "test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs", diff --git a/scripts/test-map-admins-sql.mjs b/scripts/test-map-admins-sql.mjs new file mode 100644 index 0000000..36ca369 --- /dev/null +++ b/scripts/test-map-admins-sql.mjs @@ -0,0 +1,25 @@ +import { readFile, readdir } from "node:fs/promises"; +import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js"; +const db = new PGlite(); +try { + await db.exec(`create role anon; create role authenticated; create role service_role; + create schema auth; create table auth.users(id uuid primary key); + create function auth.uid() returns uuid language sql as $$ select null::uuid $$; + create schema storage; + create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]); + create table storage.objects(id uuid primary key default gen_random_uuid(),bucket_id text,name text); + create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`); + for (const file of (await readdir("supabase/migrations")) + .filter((f) => f.endsWith(".sql")) + .sort()) + await db.exec(await readFile(`supabase/migrations/${file}`, "utf8")); + await db.exec(await readFile("tests/sql/map-admins.sql", "utf8")); + console.log( + "Map admins SQL passed: invites start pending, duplicates/self-invites blocked, accept stamps the response, deletion cascades.", + ); +} catch (error) { + console.error(error.message, error.where ?? ""); + process.exitCode = 1; +} finally { + await db.close(); +} diff --git a/shared/database.ts b/shared/database.ts index d27c2a8..8f68c26 100644 --- a/shared/database.ts +++ b/shared/database.ts @@ -367,6 +367,44 @@ export type Database = { Update: Partial; Relationships: []; }; + map_admins: { + Row: { + created_at: string; + dataset_id: string; + id: string; + invited_by: string; + responded_at: string | null; + state: string; + user_id: string; + }; + Insert: { + created_at?: string; + dataset_id: string; + id?: string; + invited_by: string; + responded_at?: string | null; + state?: string; + user_id: string; + }; + Update: { + created_at?: string; + dataset_id?: string; + id?: string; + invited_by?: string; + responded_at?: string | null; + state?: string; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: "map_admins_dataset_id_fkey"; + columns: ["dataset_id"]; + isOneToOne: false; + referencedRelation: "datasets"; + referencedColumns: ["id"]; + }, + ]; + }; catalog_servers: { Row: { address: string | null; diff --git a/shared/locales/ru.json b/shared/locales/ru.json index 76c5371..a540c4c 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -1110,5 +1110,21 @@ "3–32 letters, numbers or underscores.": "3–32 латинские буквы, цифры или символы подчёркивания.", "Save username": "Сохранить имя пользователя", "Username saved.": "Имя пользователя сохранено.", - "That username is already taken.": "Это имя пользователя уже занято." + "That username is already taken.": "Это имя пользователя уже занято.", + "Accept": "Принять", + "Decline": "Отклонить", + "Map admin": "Админ карты", + "Map admins": "Админы карты", + "Map admin invites": "Приглашения в админы карт", + "Cancel invite": "Отменить приглашение", + "Leave map": "Покинуть карту", + "Send invite": "Отправить приглашение", + "Player name or username": "Имя игрока или никнейм", + "SHARED WITH YOU": "ВАМ ПРЕДЛОЖИЛИ", + "· admin": "· админ", + "· invite pending": "· приглашение ожидает", + "{owner} invited you to manage {map}.": "{owner} приглашает вас управлять картой {map}.", + "Invite friends to manage this map with you. They can edit details, choose locations, refresh the map and handle contributions after accepting your invite.": "Пригласите друзей управлять этой картой вместе с вами. После принятия приглашения они смогут редактировать детали, выбирать локации, обновлять карту и обрабатывать панорамы от игроков.", + "You manage this map as an admin. The owner invited you to help run it.": "Вы управляете этой картой как админ. Владелец пригласил вас помогать с ней.", + "Invite sent. Your friend can accept it from their dashboard.": "Приглашение отправлено. Ваш друг сможет принять его в своём кабинете." } diff --git a/supabase/migrations/20261031120000_map_admins.sql b/supabase/migrations/20261031120000_map_admins.sql new file mode 100644 index 0000000..26a073a --- /dev/null +++ b/supabase/migrations/20261031120000_map_admins.sql @@ -0,0 +1,23 @@ +-- Map owners can invite other accounts to manage their maps together. Invites +-- stay pending until the invitee accepts from their dashboard; accepted admins +-- act through the owner, so map RPC checks keep working unchanged. +create table public.map_admins ( + id uuid primary key default gen_random_uuid(), + dataset_id uuid not null references public.datasets(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + invited_by uuid not null references auth.users(id) on delete cascade, + state text not null default 'pending' check(state in ('pending','accepted')), + created_at timestamptz not null default now(), + responded_at timestamptz, + constraint map_admins_unique unique(dataset_id,user_id), + constraint map_admins_no_self check(user_id<>invited_by), + constraint map_admins_response check( + (state='pending' and responded_at is null) or + (state='accepted' and responded_at is not null) + ) +); +create index map_admins_user_state on public.map_admins(user_id,state); +create index map_admins_dataset on public.map_admins(dataset_id); + +-- Service role only: invites and membership are managed through the API. +alter table public.map_admins enable row level security; diff --git a/tests/sql/map-admins.sql b/tests/sql/map-admins.sql new file mode 100644 index 0000000..82d4db8 --- /dev/null +++ b/tests/sql/map-admins.sql @@ -0,0 +1,24 @@ +begin; +do $$ +declare owner uuid:=gen_random_uuid(); friend uuid:=gen_random_uuid(); map uuid; manifest jsonb; invite uuid; +begin + insert into auth.users(id) values(owner),(friend); + insert into public.profiles(id) values(owner),(friend) on conflict(id) do nothing; + manifest:='{"format":"minegeo-dataset/v1","source":"plugin","mediaFormat":"webp","name":"Admin fixture","description":"","serverName":"Admin fixture server","bounds":{"minX":0,"minZ":0,"maxX":64,"maxZ":64},"viewpoints":[{"id":"p0","x":0,"y":70,"z":0},{"id":"p1","x":1,"y":70,"z":1},{"id":"p2","x":2,"y":70,"z":2},{"id":"p3","x":3,"y":70,"z":3},{"id":"p4","x":4,"y":70,"z":4}],"assets":[]}'; + map:=public.create_upload(owner,manifest); + insert into public.map_admins(dataset_id,user_id,invited_by) values(map,friend,owner) returning id into invite; + if (select state from public.map_admins where id=invite)<>'pending' then raise exception 'Invite did not start pending'; end if; + begin insert into public.map_admins(dataset_id,user_id,invited_by) values(map,friend,owner); raise exception 'Duplicate invite allowed'; + exception when unique_violation then null; end; + begin insert into public.map_admins(dataset_id,user_id,invited_by) values(map,owner,owner); raise exception 'Self invite allowed'; + exception when check_violation then null; end; + begin update public.map_admins set responded_at=now() where id=invite; raise exception 'Pending invite stamped'; + exception when check_violation then null; end; + update public.map_admins set state='accepted',responded_at=now() where id=invite; + if not exists(select 1 from public.map_admins where id=invite and state='accepted' and responded_at is not null) then raise exception 'Accept failed'; end if; + begin update public.map_admins set responded_at=null where id=invite; raise exception 'Response timestamp dropped'; + exception when check_violation then null; end; + delete from public.datasets where id=map; + if exists(select 1 from public.map_admins where id=invite) then raise exception 'Invite survived map deletion'; end if; +end; $$; +rollback;