Map admins: owners invite friends to co-manage maps
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m2s
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:
82
components/AdminInvites.tsx
Normal file
82
components/AdminInvites.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<AdminInvites onChange={load} />
|
||||
{maps.map((m) => (
|
||||
<article className="draft-row" key={m.id}>
|
||||
<div>
|
||||
@@ -135,6 +138,9 @@ export function Dashboard() {
|
||||
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
|
||||
</span>
|
||||
<h3>{m.name}</h3>
|
||||
{m.access === "admin" && (
|
||||
<span className="badge">{t("Map admin")}</span>
|
||||
)}{" "}
|
||||
<span className="badge">
|
||||
{m.contribution_status
|
||||
? t("Contribution: {status}", {
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
<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.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 && (
|
||||
<MapAdmins id={id} access={draft.access} />
|
||||
)}
|
||||
{(["uploading", "processing"].includes(draft.state) ||
|
||||
(draft.parent_dataset_id &&
|
||||
["pending", "submitted"].includes(
|
||||
|
||||
131
components/MapAdmins.tsx
Normal file
131
components/MapAdmins.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
lib/api.ts
261
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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
25
scripts/test-map-admins-sql.mjs
Normal file
25
scripts/test-map-admins-sql.mjs
Normal 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();
|
||||
}
|
||||
@@ -367,6 +367,44 @@ export type Database = {
|
||||
Update: Partial<AppealRecord>;
|
||||
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;
|
||||
|
||||
@@ -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.": "Приглашение отправлено. Ваш друг сможет принять его в своём кабинете."
|
||||
}
|
||||
|
||||
23
supabase/migrations/20261031120000_map_admins.sql
Normal file
23
supabase/migrations/20261031120000_map_admins.sql
Normal 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
24
tests/sql/map-admins.sql
Normal 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;
|
||||
Reference in New Issue
Block a user