Add account based multiplayer hosting and bug report retention

This commit is contained in:
2026-10-10 16:24:34 +05:00
parent 9af71c85c2
commit fc6da6756d
33 changed files with 1538 additions and 102 deletions

146
lib/bug-cleanup-workflow.ts Normal file
View File

@@ -0,0 +1,146 @@
import {
FatalError,
RetryableError,
getWorkflowMetadata,
sleep,
} from "workflow";
import { admin, required } from "./supabase";
import {
LinearSupportClient,
LinearPermissionError,
} from "./linear-support-client";
import { purgeClosedBugReport } from "./bug-retention";
const linearClient = () =>
new LinearSupportClient(
process.env.LINEAR_API_KEY!,
process.env.LINEAR_TEAM_ID!,
process.env.LINEAR_PROJECT_ID,
);
async function record(id: string) {
const result = await admin()
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (result.error) required(result);
return result.data;
}
export async function cleanupDoneBugReport(
id: string,
closedAt: string,
claim: string,
) {
"use workflow";
const run = getWorkflowMetadata().workflowRunId;
try {
const deadline = await markLinearDone(id, closedAt);
if (deadline) {
await sleep(new Date(deadline));
await purgeDone(id, closedAt);
}
} catch {
await releaseCleanup(id, closedAt, claim, run);
throw new Error("Bug report cleanup paused; the saved report will retry.");
}
}
async function markLinearDone(
id: string,
closedAt: string,
): Promise<string | null> {
"use step";
try {
const report = await record(id);
if (!report || report.state !== "done" || report.closed_at !== closedAt)
return null;
if (!report.linear_done_synced) {
await linearClient().done(id);
required(
await admin()
.from("bug_reports")
.update({ linear_done_synced: true, cleanup_error: null })
.eq("id", id)
.eq("state", "done")
.eq("closed_at", closedAt)
.select("id"),
);
}
return report.delete_after ?? null;
} catch (error) {
if (error instanceof LinearPermissionError)
throw new FatalError("The Linear key needs Write permission.");
throw new RetryableError(
"Could not move the Linear report to Done. Check the API key's Write permission.",
{ retryAfter: "1m" },
);
}
}
markLinearDone.maxRetries = 10;
async function purgeDone(id: string, closedAt: string) {
"use step";
try {
await purgeClosedBugReport(id, closedAt, {
record: () => record(id),
linear: linearClient(),
begin: async () =>
required(
await admin().rpc("begin_bug_purge", {
p_report: id,
p_closed_at: closedAt,
}),
),
reopen: async () => {
required(
await admin().rpc("set_bug_report_done", {
p_report: id,
p_done: false,
}),
);
},
removeFiles: async (paths) => {
required(await admin().storage.from("minegeo-support").remove(paths));
},
finish: async () => {
required(
await admin().rpc("finish_bug_purge", {
p_report: id,
p_closed_at: closedAt,
}),
);
},
});
} catch {
throw new RetryableError(
"Report deletion could not be confirmed. The saved record will retry.",
{ retryAfter: "5m" },
);
}
}
purgeDone.maxRetries = 10;
async function releaseCleanup(
id: string,
closedAt: string,
claim: string,
run: string,
) {
"use step";
required(
await admin()
.from("bug_reports")
.update({
cleanup_run_id: null,
cleanup_lease_until: new Date(Date.now() + 60000).toISOString(),
cleanup_error:
"Support cleanup paused. Check Linear Write permission and retry.",
})
.eq("id", id)
.eq("closed_at", closedAt)
.in("cleanup_run_id", [claim, run])
.select("id"),
);
console.error("Bug report cleanup paused", { id, run });
}
releaseCleanup.maxRetries = 10;

98
lib/bug-lifecycle.ts Normal file
View File

@@ -0,0 +1,98 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { start } from "workflow/api";
import { admin, required } from "./supabase";
import { LinearSupportClient } from "./linear-support-client";
import { cleanupDoneBugReport } from "./bug-cleanup-workflow";
export async function scheduleBugCleanup(id: string) {
if (!process.env.LINEAR_API_KEY || !process.env.LINEAR_TEAM_ID) return;
const db = admin(),
claim = `starting:${randomUUID()}`;
const rows = required(
await db.rpc("claim_bug_cleanup", { p_report: id, p_worker: claim }),
);
for (const report of rows) {
try {
const run = await start(cleanupDoneBugReport, [
id,
report.closed_at!,
claim,
]);
required(
await db
.from("bug_reports")
.update({ cleanup_run_id: run.runId, cleanup_lease_until: null })
.eq("id", id)
.eq("cleanup_run_id", claim)
.select("id"),
);
} catch {
console.error("Bug report cleanup remains saved for retry.");
}
}
}
export async function reconcileBugStatus(id: string, force = false) {
const db = admin();
const lookup = await db
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (lookup.error) required(lookup);
const report = lookup.data;
if (!report?.issue_synced || report.purge_started_at) return;
if (report.state === "done" && !report.linear_done_synced) {
await scheduleBugCleanup(id);
return;
}
if (
!force &&
report.status_checked_at &&
Date.parse(report.status_checked_at) > Date.now() - 60000
)
return;
required(
await db
.from("bug_reports")
.update({ status_checked_at: new Date().toISOString() })
.eq("id", id)
.select("id"),
);
const linear = new LinearSupportClient(
process.env.LINEAR_API_KEY!,
process.env.LINEAR_TEAM_ID!,
process.env.LINEAR_PROJECT_ID,
);
const issue = await linear.issue(id);
if (!issue) {
if (report.state === "done") await scheduleBugCleanup(id);
return;
}
if (issue.state?.type === "completed") {
required(
await db.rpc("set_bug_report_done", {
p_report: id,
p_done: true,
p_closed_at: issue.completedAt ?? null,
}),
);
// An external completed status may need normalizing to the team's Done status.
required(
await db
.from("bug_reports")
.update({
linear_done_synced: issue.state.name.toLowerCase() === "done",
})
.eq("id", id)
.eq("state", "done")
.select("id"),
);
await scheduleBugCleanup(id);
} else if (report.state === "done") {
required(
await db.rpc("set_bug_report_done", { p_report: id, p_done: false }),
);
}
}

View File

@@ -2,6 +2,7 @@ import "server-only";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { after } from "next/server";
import { admin, required, user } from "./supabase";
import { accountProfile } from "./profile";
import { requestOrigin } from "./request-origin";
@@ -14,6 +15,7 @@ import {
} from "../shared/bug-reports";
import { supportScreenshot } from "./support-images";
import { supportReady, syncBugAfterResponse } from "./support-sync";
import { scheduleBugCleanup, reconcileBugStatus } from "./bug-lifecycle";
type Dependencies = {
jsonBody: (request: NextRequest) => Promise<unknown>;
@@ -51,9 +53,14 @@ export async function bugReportApi(
? record.owner_id === account?.id
: !!saved && record.access_hash === hash;
async function owned(id: string) {
const record = required(
await db.from("bug_reports").select("*").eq("id", id).maybeSingle(),
);
const result = await db
.from("bug_reports")
.select("*")
.eq("id", id)
.maybeSingle();
if (result.error) required(result);
const record = result.data;
if (!record) throw new ServiceError("Report not found", 404);
if (!owns(record)) throw new ServiceError("Report not found", 404);
return record;
}
@@ -79,6 +86,10 @@ export async function bugReportApi(
description: record.description,
page_path: record.page_path,
state: record.state,
closed_at: record.closed_at,
delete_after: record.delete_after,
linear_done_synced: record.linear_done_synced,
cleanup_error: record.cleanup_error,
linear_identifier: record.linear_identifier,
glow_requested: record.glow_requested,
created_at: record.created_at,
@@ -102,6 +113,12 @@ export async function bugReportApi(
.order("created_at", { ascending: false })
.limit(50),
);
after(async () => {
for (let i = 0; i < rows.length; i += 5)
await Promise.allSettled(
rows.slice(i, i + 5).map((row) => reconcileBugStatus(row.id)),
);
});
return respond({
ready: supportReady(),
signedIn: !!account,
@@ -179,8 +196,14 @@ export async function bugReportApi(
const id = z.string().uuid().parse(path[1]);
const record = await owned(id);
if (path.length === 2 && request.method === "GET") {
try {
await reconcileBugStatus(id);
} catch {
console.error("Bug report status will retry.");
}
if (record.state !== "draft") syncBugAfterResponse(id);
return respond(await view(record));
if (record.state === "done") await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path.length !== 3 || request.method !== "POST")
return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -243,6 +266,8 @@ export async function bugReportApi(
return respond(await view(await owned(id)));
}
if (path[2] === "messages") {
if (record.state === "done")
throw new ServiceError("This bug report is Done", 409);
if (record.state === "draft")
throw new ServiceError("Submit this report before sending messages", 409);
const body = bugMessageSchema.parse(await jsonBody(request));
@@ -289,6 +314,36 @@ export async function bugReportApi(
syncBugAfterResponse(id);
return respond(await view(record));
}
if (path[2] === "close") {
z.object({})
.strict()
.parse(await jsonBody(request));
if (!record.issue_synced)
throw new ServiceError(
"Wait for support to receive this report before closing it",
409,
);
required(
await db.rpc("set_bug_report_done", { p_report: id, p_done: true }),
);
await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path[2] === "retry-cleanup" && record.state === "done") {
z.object({})
.strict()
.parse(await jsonBody(request));
required(
await db
.from("bug_reports")
.update({ cleanup_lease_until: null })
.eq("id", id)
.is("cleanup_run_id", null)
.select("id"),
);
await scheduleBugCleanup(id);
return respond(await view(await owned(id)));
}
if (path[2] === "claim") {
if (!account) throw new ServiceError("Sign in to link this report", 401);
required(

38
lib/bug-retention.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { BugReportRecord } from "../shared/bug-reports";
import type { LinearSupportClient } from "./linear-support-client";
export async function purgeClosedBugReport(
id: string,
closedAt: string,
deps: {
record: () => Promise<BugReportRecord | null>;
linear: Pick<LinearSupportClient, "issue" | "remove">;
begin: () => Promise<boolean>;
reopen: () => Promise<void>;
removeFiles: (paths: string[]) => Promise<void>;
finish: () => Promise<void>;
now?: () => number;
},
) {
const report = await deps.record();
if (
!report ||
report.state !== "done" ||
report.closed_at !== closedAt ||
!report.delete_after ||
Date.parse(report.delete_after) > (deps.now ?? Date.now)()
)
return;
if (!report.purge_started_at) {
const issue = await deps.linear.issue(id);
if (issue && issue.state?.type !== "completed") {
await deps.reopen();
return;
}
}
if (!(await deps.begin())) return;
// Keep the local retry record until BOTH external deletions are confirmed.
await deps.linear.remove(id);
await deps.removeFiles([`${id}/input`, `${id}/screenshot.webp`]);
await deps.finish();
}

View File

@@ -83,16 +83,27 @@ async function announcementSource(
const players = required(
await db
.from("multiplayer_players")
.select("id,display_name")
.select("id,display_name,account_id")
.eq("room_id", room.id)
.eq("has_left", false),
);
const host = players.find((p) => p.id === room.host_player_id);
if (!host || players.length >= room.player_limit) return null;
let hostName = host.display_name;
if (host.account_id) {
const profile = required(
await db
.from("profiles")
.select("display_name")
.eq("id", host.account_id)
.single(),
);
hostName = profile.display_name;
}
return {
id: room.id,
name: map.data.name,
owner: host.display_name,
owner: hostName,
difficulty: room.difficulty,
players: players.length,
limit: room.player_limit,

View File

@@ -3,7 +3,14 @@ import {
type BugReportRecord,
} from "../shared/bug-reports";
type Issue = { id: string; identifier: string; team: { id: string } };
type Issue = {
id: string;
identifier: string;
team: { id: string };
state?: { id: string; name: string; type: string };
completedAt?: string | null;
};
export class LinearPermissionError extends Error {}
export class LinearSupportClient {
constructor(
private key: string,
@@ -34,13 +41,26 @@ export class LinearSupportClient {
)
)
return null;
if (
response.status === 401 ||
response.status === 403 ||
result.errors?.some(
(error: { message?: string; extensions?: { code?: string } }) =>
/forbidden|permission|authentication|unauthorized/i.test(
`${error.extensions?.code ?? ""} ${error.message ?? ""}`,
),
)
)
throw new LinearPermissionError(
"Support delivery is temporarily unavailable. Check Linear Write permission.",
);
if (!response.ok || result.errors?.length || !result.data)
throw new Error("Support delivery is temporarily unavailable");
return result.data;
}
async issue(id: string): Promise<Issue | null> {
const data = await this.graphql<{ issue: Issue }>(
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } } }",
"query SupportIssue($id: String!) { issue(id: $id) { id identifier team { id } state { id name type } completedAt } }",
{ id },
true,
);
@@ -49,6 +69,47 @@ export class LinearSupportClient {
throw new Error("Support issue team mismatch");
return issue;
}
async done(id: string) {
const issue = await this.issue(id);
if (!issue) throw new Error("Support issue was not found");
if (
issue.state?.type === "completed" &&
issue.state.name.toLowerCase() === "done"
)
return;
const data = await this.graphql<{
team: { states: { nodes: { id: string; name: string; type: string }[] } };
}>(
"query SupportDoneState($team: String!) { team(id:$team) { states { nodes { id name type } } } }",
{ team: this.team },
);
const state = data?.team.states.nodes.find(
(s) => s.type === "completed" && s.name.toLowerCase() === "done",
);
if (!state)
throw new Error("The support team needs a Done workflow status");
const result = await this.graphql<{
issueUpdate: { success: boolean; issue: Issue };
}>(
"mutation SupportDone($id: String!, $input: IssueUpdateInput!) { issueUpdate(id:$id,input:$input) { success issue { id state { id name type } } } }",
{ id, input: { stateId: state.id } },
);
if (
!result?.issueUpdate.success ||
result.issueUpdate.issue.id !== id ||
result.issueUpdate.issue.state?.id !== state.id
)
throw new Error("Support issue status was not confirmed");
}
async remove(id: string) {
if (!(await this.issue(id))) return;
const result = await this.graphql<{ issueDelete: { success: boolean } }>(
"mutation SupportDelete($id: String!) { issueDelete(id:$id) { success } }",
{ id },
);
if (!result?.issueDelete.success)
throw new Error("Support issue deletion was not confirmed");
}
async createIssue(report: BugReportRecord): Promise<Issue> {
const existing = await this.issue(report.id);
if (existing) return existing;
@@ -65,6 +126,7 @@ export class LinearSupportClient {
"",
"Reply to the player with #player-report-send followed by your message.",
"Grant or remove the username reward with #player-report-glow grant or #player-report-glow revoke.",
"Close this report with #player-report-close or move the issue to Done. Closed reports and issues are deleted after three days.",
`Report reference: ${report.id}`,
].join("\n");
try {

View File

@@ -61,30 +61,22 @@ export async function multiplayerApi(
return response;
};
if (request.method === "POST" && path.length === 1) {
const account = await user();
if (!account)
throw new ServiceError("Log in to create a multiplayer room", 401);
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
const account = await user();
if (account) await accountProfile(account);
const profile = await accountProfile(account);
const id = required(
await client.rpc("create_map_room", {
p_dataset: body.datasetId,
p_difficulty: body.difficulty,
p_name: body.displayName,
p_name: profile.display_name,
p_limit: body.playerLimit,
p_access_hash: accessHash,
p_owner: account?.id ?? null,
p_owner: account.id,
}),
);
if (account)
required(
await client
.from("multiplayer_players")
.update({ account_id: account.id })
.eq("room_id", id)
.eq("access_hash", accessHash)
.is("account_id", null)
.select("id"),
);
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
@@ -151,11 +143,18 @@ export async function multiplayerApi(
});
}
let payload: Record<string, string | number> = {};
let joiningAccount: string | null = null;
if (request.method === "POST" && path.length === 3) {
const input = await jsonBody(request);
if (action === "join") {
payload = { name: joinRoomSchema.parse(input).displayName };
await limit("multiplayer:join:" + accessHash, 40, 3600);
const account = await user();
if (account) {
const profile = await accountProfile(account);
payload.name = profile.display_name;
joiningAccount = account.id;
}
} else if (action === "guess") payload = roomGuessSchema.parse(input);
else if (action === "ready" || action === "next")
payload = roomRoundSchema.parse(input);
@@ -172,12 +171,19 @@ export async function multiplayerApi(
return NextResponse.json({ error: "Not found" }, { status: 404 });
const room = required(
await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: request.method === "GET" ? "view" : action,
p_payload: payload,
}),
action === "join"
? await client.rpc("join_multiplayer_room", {
p_room: id,
p_access_hash: accessHash,
p_owner: joiningAccount,
p_name: String(payload.name),
})
: await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: request.method === "GET" ? "view" : action,
p_payload: payload,
}),
) as unknown as RoomView;
if (room.status === "closed") {
try {
@@ -186,22 +192,6 @@ export async function multiplayerApi(
console.error("Closed Discord party remains queued for cleanup.");
}
}
if (action === "join" && room.you) {
const account = await user();
if (account) {
await accountProfile(account);
required(
await client
.from("multiplayer_players")
.update({ account_id: account.id })
.eq("id", room.you)
.eq("room_id", id)
.eq("access_hash", accessHash)
.is("account_id", null)
.select("id"),
);
}
}
if (action !== "media") {
const appearances = await multiplayerAppearance(
id,

View File

@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import { after } from "next/server";
import { admin, required } from "./supabase";
import { LinearSupportClient } from "./linear-support-client";
import { scheduleBugCleanup } from "./bug-lifecycle";
export function supportReady() {
return !!(
@@ -58,13 +59,20 @@ export async function syncBugReport(id: string) {
.update({
issue_synced: true,
linear_identifier: issue.identifier,
state: "open",
})
.eq("id", id)
.eq("lease_owner", worker)
.select("id")
.single(),
);
required(
await db
.from("bug_reports")
.update({ state: "open" })
.eq("id", id)
.eq("state", "pending")
.select("id"),
);
}
required(
await db
@@ -102,6 +110,7 @@ export async function syncBugReport(id: string) {
.eq("lease_owner", worker)
.select("id"),
);
await scheduleBugCleanup(id);
} catch {
console.error("Bug report delivery paused; the saved report will retry.");
required(