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:
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")
|
||||
|
||||
Reference in New Issue
Block a user