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

@@ -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")