Add account based multiplayer hosting and bug report retention
This commit is contained in:
146
lib/bug-cleanup-workflow.ts
Normal file
146
lib/bug-cleanup-workflow.ts
Normal 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
98
lib/bug-lifecycle.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
38
lib/bug-retention.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user