Map admins: owners invite friends to co-manage maps
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m2s

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.
This commit is contained in:
2026-10-31 17:28:05 +05:00
parent 0d62f08e77
commit 05854818a0
11 changed files with 603 additions and 18 deletions

View File

@@ -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<AdminInvite[]>([]);
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 (
<section className="token-box">
<span className="eyebrow">{t("SHARED WITH YOU")}</span>
<h2>{t("Map admin invites")}</h2>
{invites.map((invite) => (
<div className="draft-row" key={invite.id}>
<span>
{t("{owner} invited you to manage {map}.", {
owner: invite.ownerName,
map: invite.mapName,
})}
</span>
<div className="inline-actions">
<button
className="button primary"
disabled={busy}
onClick={() => respond(invite.id, "accept")}
>
{t("Accept")}
</button>
<button
className="button"
disabled={busy}
onClick={() => respond(invite.id, "decline")}
>
{t("Decline")}
</button>
</div>
</div>
))}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</section>
);
}

View File

@@ -11,7 +11,9 @@ import type { MapListing } from "@/lib/catalog";
import type { ProcessingJob } from "@/shared/processing-progress"; import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress"; import { ProcessingProgress } from "./ProcessingProgress";
import { ImportMap } from "./MapTransfer"; import { ImportMap } from "./MapTransfer";
import { AdminInvites } from "./AdminInvites";
type Draft = MapListing & { type Draft = MapListing & {
access?: "owner" | "admin";
upload_jobs: ProcessingJob | null; upload_jobs: ProcessingJob | null;
}; };
export function Dashboard() { export function Dashboard() {
@@ -128,6 +130,7 @@ export function Dashboard() {
</Link> </Link>
</div> </div>
)} )}
<AdminInvites onChange={load} />
{maps.map((m) => ( {maps.map((m) => (
<article className="draft-row" key={m.id}> <article className="draft-row" key={m.id}>
<div> <div>
@@ -135,6 +138,9 @@ export function Dashboard() {
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")} {m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span> </span>
<h3>{m.name}</h3> <h3>{m.name}</h3>
{m.access === "admin" && (
<span className="badge">{t("Map admin")}</span>
)}{" "}
<span className="badge"> <span className="badge">
{m.contribution_status {m.contribution_status
? t("Contribution: {status}", { ? t("Contribution: {status}", {

View File

@@ -13,8 +13,10 @@ import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link"; import Link from "next/link";
import { MapRefresh } from "./MapRefresh"; import { MapRefresh } from "./MapRefresh";
import { MapAppeal } from "./MapAppeal"; import { MapAppeal } from "./MapAppeal";
import { MapAdmins } from "./MapAdmins";
import { ExportMap } from "./MapTransfer"; import { ExportMap } from "./MapTransfer";
type Draft = { type Draft = {
access: "owner" | "admin";
imported_from_id: string | null; imported_from_id: string | null;
imported_owner_name: string | null; imported_owner_name: string | null;
ownsPublishedParent: boolean; ownsPublishedParent: boolean;
@@ -175,6 +177,13 @@ export function DraftEditor({
)} )}
</div> </div>
<span className="badge">{t(statusMessage(draft.state))}</span> <span className="badge">{t(statusMessage(draft.state))}</span>
{draft.access === "admin" && (
<p className="muted">
{t(
"You manage this map as an admin. The owner invited you to help run it.",
)}
</p>
)}
{!draft.imported_from_id && {!draft.imported_from_id &&
!draft.parent_dataset_id && !draft.parent_dataset_id &&
draft.map && draft.map &&
@@ -440,6 +449,9 @@ export function DraftEditor({
onChange={() => setVersion((v) => v + 1)} onChange={() => setVersion((v) => v + 1)}
/> />
)} )}
{!draft.imported_from_id && !draft.parent_dataset_id && (
<MapAdmins id={id} access={draft.access} />
)}
{(["uploading", "processing"].includes(draft.state) || {(["uploading", "processing"].includes(draft.state) ||
(draft.parent_dataset_id && (draft.parent_dataset_id &&
["pending", "submitted"].includes( ["pending", "submitted"].includes(

131
components/MapAdmins.tsx Normal file
View File

@@ -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<MapAdmin[]>([]);
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 (
<section className="tool-card">
<h2>{t("Map admins")}</h2>
<p className="muted">
{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.",
)}
</p>
{admins.map((a) => (
<div className="draft-row" key={a.id}>
<span>
{a.name}
{a.username ? <small className="muted"> @{a.username}</small> : null}{" "}
<small className="muted">
{a.state === "accepted" ? t("· admin") : t("· invite pending")}
</small>
</span>
{(access === "owner" || a.user_id === caller) && (
<button
className="button"
disabled={busy}
onClick={() => remove(a.user_id)}
>
{a.user_id === caller && access !== "owner"
? t("Leave map")
: a.state === "accepted"
? t("Remove")
: t("Cancel invite")}
</button>
)}
</div>
))}
{access === "owner" && (
<form onSubmit={invite}>
<label className="field">
{t("Player name or username")}
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={80}
/>
</label>
<button className="button primary" disabled={busy || !name.trim()}>
{busy ? t("Sending…") : t("Send invite")}
</button>
</form>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
{message && (
<p role="status" className="success-message">
{t(message)}
</p>
)}
</section>
);
}

View File

@@ -105,18 +105,31 @@ async function limit(key: string, count: number, seconds: number) {
if (!allowed) if (!allowed)
throw new Error("Too many requests. Please wait and try again."); throw new Error("Too many requests. Please wait and try again.");
} }
async function ownedDataset(request: NextRequest, id: string) { async function managedDataset(
const owner = await contributor(request); request: NextRequest,
id: string,
ownerOnly = false,
) {
const caller = await contributor(request);
const dataset = required( 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() await admin()
.from("datasets") .from("map_admins")
.select("*") .select("id")
.eq("id", uuid(id)) .eq("dataset_id", dataset.id)
.eq("owner_id", owner) .eq("user_id", caller)
.eq("state", "accepted")
.single(), .single(),
); );
// The ownership predicate above proves this is a current account's map. return { ...dataset, owner_id: ownerId, access: "admin" as const, caller };
return { ...dataset, owner_id: owner };
} }
async function session(request: NextRequest, id: string) { async function session(request: NextRequest, id: string) {
const record = required( const record = required(
@@ -420,7 +433,7 @@ export async function handle(request: NextRequest, path: string[]) {
const body = z const body = z
.object({ datasetId: idSchema }) .object({ datasetId: idSchema })
.parse(await jsonBody(request)); .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 (method === "POST" && path[1] === "sign") {
if (d.state !== "uploading") if (d.state !== "uploading")
throw new Error("Dataset no longer accepts uploads"); 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 }), .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({ return NextResponse.json({
maps: await Promise.all( maps: await Promise.all(
maps.map(async (m) => { all.map(async (m) => {
if (needsProcessing(m.upload_jobs)) if (needsProcessing(m.upload_jobs))
processUploadAfterResponse(m.id); processUploadAfterResponse(m.id);
return { 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 (method === "GET" && path[2] === "export" && path.length === 3) {
if ( if (
d.imported_from_id || d.imported_from_id ||
@@ -783,14 +929,13 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
if (method === "POST" && path[2] === "publish-update") { if (method === "POST" && path[2] === "publish-update") {
const owner = await contributor(request, true); // Browser session required; membership was verified by managedDataset.
if (owner !== d.owner_id) await contributor(request, true);
throw new ServiceError("Map owner permission required", 403);
checkServiceError( checkServiceError(
( (
await client.rpc("publish_owner_update", { await client.rpc("publish_owner_update", {
p_dataset: d.id, p_dataset: d.id,
p_owner: owner, p_owner: d.owner_id,
}) })
).error, ).error,
); );
@@ -865,7 +1010,7 @@ export async function handle(request: NextRequest, path: string[]) {
); );
return NextResponse.json(info); 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 (parent.parent_dataset_id) throw new Error("Choose the original map");
if (method === "GET" && path[2] === "inbox" && path.length === 3) { if (method === "GET" && path[2] === "inbox" && path.length === 3) {
const rows = required( const rows = required(
@@ -934,7 +1079,7 @@ export async function handle(request: NextRequest, path: string[]) {
const { error } = await client.rpc("review_contribution", { const { error } = await client.rpc("review_contribution", {
p_dataset: target, p_dataset: target,
p_contribution: child.id, p_contribution: child.id,
p_owner: owner, p_owner: parent.owner_id,
p_decision: body.decision, p_decision: body.decision,
p_reason: body.reason ?? undefined, p_reason: body.reason ?? undefined,
}); });
@@ -942,6 +1087,88 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true }); 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") { if (path[0] === "tokens") {
const owner = await contributor(request, true); const owner = await contributor(request, true);
if (method === "GET") if (method === "GET")

View File

@@ -21,6 +21,7 @@
"bots": "node scripts/moderator-bots.mjs", "bots": "node scripts/moderator-bots.mjs",
"test": "tsx --test tests/*.test.ts", "test": "tsx --test tests/*.test.ts",
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs", "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:moderator-applications": "node scripts/test-moderator-applications-sql.mjs",
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs", "test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs", "test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",

View File

@@ -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();
}

View File

@@ -367,6 +367,44 @@ export type Database = {
Update: Partial<AppealRecord>; Update: Partial<AppealRecord>;
Relationships: []; 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: { catalog_servers: {
Row: { Row: {
address: string | null; address: string | null;

View File

@@ -1110,5 +1110,21 @@
"3–32 letters, numbers or underscores.": "3–32 латинские буквы, цифры или символы подчёркивания.", "3–32 letters, numbers or underscores.": "3–32 латинские буквы, цифры или символы подчёркивания.",
"Save username": "Сохранить имя пользователя", "Save username": "Сохранить имя пользователя",
"Username saved.": "Имя пользователя сохранено.", "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.": "Приглашение отправлено. Ваш друг сможет принять его в своём кабинете."
} }

View File

@@ -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;

24
tests/sql/map-admins.sql Normal file
View File

@@ -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;