From 5b90c16cc1bb2fa90c0b869678484204167820f2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 11:36:20 -0500 Subject: [PATCH 01/11] feat: add github_repo support to PATCH /api/sandboxes Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 28 +++++++++++++++++++ .../validateSnapshotPatchBody.test.ts | 21 ++++++++++++++ lib/sandbox/updateSnapshotPatchHandler.ts | 1 + lib/sandbox/validateSnapshotPatchBody.ts | 6 +++- .../upsertAccountSnapshot.ts | 3 ++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 95b51a6b..96adfffa 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -95,6 +95,34 @@ describe("updateSnapshotPatchHandler", () => { }); }); + it("forwards githubRepo to upsertAccountSnapshot when provided", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + snapshotId: "snap_abc123", + githubRepo: "https://github.com/org/repo", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_123", + snapshot_id: "snap_abc123", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).toHaveBeenCalledWith({ + accountId: "acc_123", + snapshotId: "snap_abc123", + githubRepo: "https://github.com/org/repo", + }); + }); + it("returns 400 when upsertAccountSnapshot returns error", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts index af6634d8..0caf3880 100644 --- a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -122,6 +122,27 @@ describe("validateSnapshotPatchBody", () => { expect((result as NextResponse).status).toBe(403); }); + it("returns validated body with github_repo when provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + github_repo: "https://github.com/org/repo", + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + snapshotId: "snap_abc123", + githubRepo: "https://github.com/org/repo", + }); + }); + it("returns error when account_id is not a valid UUID", async () => { vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123", diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index b1ea91be..4e07c889 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -24,6 +24,7 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< const result = await upsertAccountSnapshot({ accountId: validated.accountId, snapshotId: validated.snapshotId, + githubRepo: validated.githubRepo, }); if (result.error || !result.data) { diff --git a/lib/sandbox/validateSnapshotPatchBody.ts b/lib/sandbox/validateSnapshotPatchBody.ts index 08f33c17..ad80c749 100644 --- a/lib/sandbox/validateSnapshotPatchBody.ts +++ b/lib/sandbox/validateSnapshotPatchBody.ts @@ -8,6 +8,7 @@ import { z } from "zod"; export const snapshotPatchBodySchema = z.object({ snapshotId: z.string({ message: "snapshotId is required" }).min(1, "snapshotId cannot be empty"), account_id: z.string().uuid("account_id must be a valid UUID").optional(), + github_repo: z.string().url("github_repo must be a valid URL").optional(), }); export type SnapshotPatchBody = { @@ -15,6 +16,8 @@ export type SnapshotPatchBody = { accountId: string; /** The snapshot ID to set */ snapshotId: string; + /** The GitHub repository URL to associate with the sandbox */ + githubRepo?: string; }; /** @@ -46,7 +49,7 @@ export async function validateSnapshotPatchBody( ); } - const { snapshotId, account_id: targetAccountId } = result.data; + const { snapshotId, account_id: targetAccountId, github_repo: githubRepo } = result.data; const authResult = await validateAuthContext(request, { accountId: targetAccountId, @@ -59,5 +62,6 @@ export async function validateSnapshotPatchBody( return { accountId: authResult.accountId, snapshotId, + ...(githubRepo && { githubRepo }), }; } diff --git a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts index e5dba837..9241254f 100644 --- a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts +++ b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts @@ -5,6 +5,7 @@ import type { PostgrestError } from "@supabase/supabase-js"; interface UpsertAccountSnapshotParams { accountId: string; snapshotId: string; + githubRepo?: string; } interface UpsertAccountSnapshotResult { @@ -25,6 +26,7 @@ interface UpsertAccountSnapshotResult { export async function upsertAccountSnapshot({ accountId, snapshotId, + githubRepo, }: UpsertAccountSnapshotParams): Promise { // Set expiration to 1 year from now const expiresAt = new Date(); @@ -37,6 +39,7 @@ export async function upsertAccountSnapshot({ account_id: accountId, snapshot_id: snapshotId, expires_at: expiresAt.toISOString(), + ...(githubRepo !== undefined && { github_repo: githubRepo }), }, { onConflict: "account_id" }, ) From c2c370d653c52d6f4daee5dac37ac76c10fc0623 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 13:43:41 -0500 Subject: [PATCH 02/11] refactor: use TablesInsert<"account_snapshots"> instead of manual params type Removes duplicate UpsertAccountSnapshotParams interface in favor of the Supabase-generated TablesInsert type. Callers now pass snake_case fields directly, eliminating the camelCase-to-snake_case mapping layer. Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 22 +++++++----- lib/sandbox/updateSnapshotPatchHandler.ts | 7 ++-- .../upsertAccountSnapshot.ts | 34 ++++--------------- 3 files changed, 23 insertions(+), 40 deletions(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 96adfffa..3b5da324 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -89,10 +89,12 @@ describe("updateSnapshotPatchHandler", () => { const request = createMockRequest(); await updateSnapshotPatchHandler(request); - expect(upsertAccountSnapshot).toHaveBeenCalledWith({ - accountId: "acc_456", - snapshotId: "snap_xyz", - }); + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_456", + snapshot_id: "snap_xyz", + }), + ); }); it("forwards githubRepo to upsertAccountSnapshot when provided", async () => { @@ -116,11 +118,13 @@ describe("updateSnapshotPatchHandler", () => { const request = createMockRequest(); await updateSnapshotPatchHandler(request); - expect(upsertAccountSnapshot).toHaveBeenCalledWith({ - accountId: "acc_123", - snapshotId: "snap_abc123", - githubRepo: "https://github.com/org/repo", - }); + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/org/repo", + }), + ); }); it("returns 400 when upsertAccountSnapshot returns error", async () => { diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 4e07c889..68df0b78 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -22,9 +22,10 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< try { const result = await upsertAccountSnapshot({ - accountId: validated.accountId, - snapshotId: validated.snapshotId, - githubRepo: validated.githubRepo, + account_id: validated.accountId, + snapshot_id: validated.snapshotId, + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + ...(validated.githubRepo && { github_repo: validated.githubRepo }), }); if (result.error || !result.data) { diff --git a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts index 9241254f..239be351 100644 --- a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts +++ b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts @@ -1,13 +1,7 @@ import supabase from "../serverClient"; -import type { Tables } from "@/types/database.types"; +import type { Tables, TablesInsert } from "@/types/database.types"; import type { PostgrestError } from "@supabase/supabase-js"; -interface UpsertAccountSnapshotParams { - accountId: string; - snapshotId: string; - githubRepo?: string; -} - interface UpsertAccountSnapshotResult { data: Tables<"account_snapshots"> | null; error: PostgrestError | null; @@ -18,31 +12,15 @@ interface UpsertAccountSnapshotResult { * Creates a new record if one doesn't exist for the account, * or updates the existing record if one already exists. * - * @param params - The upsert parameters - * @param params.accountId - The account ID to associate with the snapshot - * @param params.snapshotId - The snapshot ID to set for the account + * @param params - The upsert parameters matching the account_snapshots table schema * @returns The upserted record or error */ -export async function upsertAccountSnapshot({ - accountId, - snapshotId, - githubRepo, -}: UpsertAccountSnapshotParams): Promise { - // Set expiration to 1 year from now - const expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - +export async function upsertAccountSnapshot( + params: TablesInsert<"account_snapshots">, +): Promise { const { data, error } = await supabase .from("account_snapshots") - .upsert( - { - account_id: accountId, - snapshot_id: snapshotId, - expires_at: expiresAt.toISOString(), - ...(githubRepo !== undefined && { github_repo: githubRepo }), - }, - { onConflict: "account_id" }, - ) + .upsert(params, { onConflict: "account_id" }) .select("*") .single(); From 1193108bcea80ada69fff6f939f517f33528766e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 14:04:24 -0500 Subject: [PATCH 03/11] feat: make snapshotId optional in PATCH /api/sandboxes As a PATCH endpoint, all fields should be optional so callers can update github_repo without re-sending snapshotId, or vice versa. Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 30 +++++++++++++++++++ .../validateSnapshotPatchBody.test.ts | 19 ++++++++---- lib/sandbox/updateSnapshotPatchHandler.ts | 2 +- lib/sandbox/validateSnapshotPatchBody.ts | 6 ++-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 3b5da324..a5586649 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -127,6 +127,36 @@ describe("updateSnapshotPatchHandler", () => { ); }); + it("calls upsertAccountSnapshot without snapshot_id when not provided", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + githubRepo: "https://github.com/org/repo", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_123", + snapshot_id: "snap_existing", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + await updateSnapshotPatchHandler(request); + + const call = vi.mocked(upsertAccountSnapshot).mock.calls[0][0]; + expect(call).not.toHaveProperty("snapshot_id"); + expect(call).toEqual( + expect.objectContaining({ + account_id: "acc_123", + github_repo: "https://github.com/org/repo", + }), + ); + }); + it("returns 400 when upsertAccountSnapshot returns error", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts index 0caf3880..895141f4 100644 --- a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -59,16 +59,23 @@ describe("validateSnapshotPatchBody", () => { }); }); - it("returns error response when snapshotId is missing", async () => { - vi.mocked(safeParseJson).mockResolvedValue({}); + it("returns validated body when only github_repo is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + github_repo: "https://github.com/org/repo", + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); const request = createMockRequest(); const result = await validateSnapshotPatchBody(request); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - const json = await (result as NextResponse).json(); - expect(json.error).toContain("snapshotId"); + expect(result).toEqual({ + accountId: "acc_123", + githubRepo: "https://github.com/org/repo", + }); }); it("returns error response when snapshotId is empty string", async () => { diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 68df0b78..73c7640e 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -23,8 +23,8 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< try { const result = await upsertAccountSnapshot({ account_id: validated.accountId, - snapshot_id: validated.snapshotId, expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + ...(validated.snapshotId && { snapshot_id: validated.snapshotId }), ...(validated.githubRepo && { github_repo: validated.githubRepo }), }); diff --git a/lib/sandbox/validateSnapshotPatchBody.ts b/lib/sandbox/validateSnapshotPatchBody.ts index ad80c749..c31c75aa 100644 --- a/lib/sandbox/validateSnapshotPatchBody.ts +++ b/lib/sandbox/validateSnapshotPatchBody.ts @@ -6,7 +6,7 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; export const snapshotPatchBodySchema = z.object({ - snapshotId: z.string({ message: "snapshotId is required" }).min(1, "snapshotId cannot be empty"), + snapshotId: z.string().min(1, "snapshotId cannot be empty").optional(), account_id: z.string().uuid("account_id must be a valid UUID").optional(), github_repo: z.string().url("github_repo must be a valid URL").optional(), }); @@ -15,7 +15,7 @@ export type SnapshotPatchBody = { /** The account ID to update */ accountId: string; /** The snapshot ID to set */ - snapshotId: string; + snapshotId?: string; /** The GitHub repository URL to associate with the sandbox */ githubRepo?: string; }; @@ -61,7 +61,7 @@ export async function validateSnapshotPatchBody( return { accountId: authResult.accountId, - snapshotId, + ...(snapshotId && { snapshotId }), ...(githubRepo && { githubRepo }), }; } From 973e4bdca5cd320f775da01c5c73ed2b34049c64 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 14:06:33 -0500 Subject: [PATCH 04/11] test: add case for empty PATCH body with no snapshotId or github_repo Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateSnapshotPatchBody.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts index 895141f4..e1b2bfea 100644 --- a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -78,6 +78,22 @@ describe("validateSnapshotPatchBody", () => { }); }); + it("returns validated body when neither snapshotId nor github_repo is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + }); + }); + it("returns error response when snapshotId is empty string", async () => { vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "" }); From b224c5f687c6a1565851e6388075732117f0fa4c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 14:07:35 -0500 Subject: [PATCH 05/11] fix: only set expires_at when snapshot_id is provided expires_at represents the snapshot expiration, so it should only be set/refreshed when a snapshot_id is being updated. Co-Authored-By: Claude Opus 4.5 --- lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts | 2 ++ lib/sandbox/updateSnapshotPatchHandler.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index a5586649..5fcd362d 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -93,6 +93,7 @@ describe("updateSnapshotPatchHandler", () => { expect.objectContaining({ account_id: "acc_456", snapshot_id: "snap_xyz", + expires_at: expect.any(String), }), ); }); @@ -149,6 +150,7 @@ describe("updateSnapshotPatchHandler", () => { const call = vi.mocked(upsertAccountSnapshot).mock.calls[0][0]; expect(call).not.toHaveProperty("snapshot_id"); + expect(call).not.toHaveProperty("expires_at"); expect(call).toEqual( expect.objectContaining({ account_id: "acc_123", diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 73c7640e..196b3140 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -23,8 +23,10 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< try { const result = await upsertAccountSnapshot({ account_id: validated.accountId, - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), - ...(validated.snapshotId && { snapshot_id: validated.snapshotId }), + ...(validated.snapshotId && { + snapshot_id: validated.snapshotId, + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }), ...(validated.githubRepo && { github_repo: validated.githubRepo }), }); From 641ba264ace2af65ca00f9b7cd0e8d1a26a20858 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 14:17:16 -0500 Subject: [PATCH 06/11] fix: skip upsert when no fields to update in PATCH /api/sandboxes Returns 200 with { success: true } immediately when neither snapshotId nor github_repo is provided, avoiding an unnecessary Supabase call that would fail due to required columns. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/updateSnapshotPatchHandler.test.ts | 16 ++++++++++++++++ lib/sandbox/updateSnapshotPatchHandler.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 5fcd362d..972720e8 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -159,6 +159,22 @@ describe("updateSnapshotPatchHandler", () => { ); }); + it("skips upsert and returns 200 when no fields to update", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ success: true }); + }); + it("returns 400 when upsertAccountSnapshot returns error", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 196b3140..e5ecca01 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -20,6 +20,13 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< return validated; } + if (!validated.snapshotId && !validated.githubRepo) { + return NextResponse.json( + { success: true }, + { status: 200, headers: getCorsHeaders() }, + ); + } + try { const result = await upsertAccountSnapshot({ account_id: validated.accountId, From 1c31c72df99adebca1716820a88262f52e7f862e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 14:46:19 -0500 Subject: [PATCH 07/11] fix: use UPDATE instead of UPSERT for github_repo-only patches UPSERT requires all NOT NULL columns (snapshot_id, expires_at), so github_repo-only requests failed with a 400. Now the handler uses updateAccountSnapshot (plain UPDATE) when only github_repo is provided, and upsertAccountSnapshot when snapshot_id is included. Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 26 ++++++++-------- lib/sandbox/updateSnapshotPatchHandler.ts | 24 ++++++++------- .../updateAccountSnapshot.ts | 30 +++++++++++++++++++ 3 files changed, 58 insertions(+), 22 deletions(-) create mode 100644 lib/supabase/account_snapshots/updateAccountSnapshot.ts diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 972720e8..99e8dec1 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; import { updateSnapshotPatchHandler } from "../updateSnapshotPatchHandler"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; vi.mock("@/lib/sandbox/validateSnapshotPatchBody", () => ({ validateSnapshotPatchBody: vi.fn(), @@ -14,6 +15,10 @@ vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ upsertAccountSnapshot: vi.fn(), })); +vi.mock("@/lib/supabase/account_snapshots/updateAccountSnapshot", () => ({ + updateAccountSnapshot: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -128,35 +133,32 @@ describe("updateSnapshotPatchHandler", () => { ); }); - it("calls upsertAccountSnapshot without snapshot_id when not provided", async () => { + it("uses updateAccountSnapshot when only github_repo is provided", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", githubRepo: "https://github.com/org/repo", }); - vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + vi.mocked(updateAccountSnapshot).mockResolvedValue({ data: { account_id: "acc_123", snapshot_id: "snap_existing", expires_at: "2025-01-01T00:00:00.000Z", created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", }, error: null, }); const request = createMockRequest(); - await updateSnapshotPatchHandler(request); + const response = await updateSnapshotPatchHandler(request); - const call = vi.mocked(upsertAccountSnapshot).mock.calls[0][0]; - expect(call).not.toHaveProperty("snapshot_id"); - expect(call).not.toHaveProperty("expires_at"); - expect(call).toEqual( - expect.objectContaining({ - account_id: "acc_123", - github_repo: "https://github.com/org/repo", - }), - ); + expect(upsertAccountSnapshot).not.toHaveBeenCalled(); + expect(updateAccountSnapshot).toHaveBeenCalledWith("acc_123", { + github_repo: "https://github.com/org/repo", + }); + expect(response.status).toBe(200); }); it("skips upsert and returns 200 when no fields to update", async () => { diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index e5ecca01..c3abc75e 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -3,12 +3,14 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; /** * Handler for PATCH /api/sandboxes/snapshot. * - * Updates the snapshot ID for an account. This snapshot will be used - * as the base environment when creating new sandboxes. + * Updates the snapshot ID and/or github_repo for an account. + * Uses upsert when snapshot_id is provided (may create a new record), + * or update when only github_repo is provided (modifies existing record). * Requires authentication via x-api-key header or Authorization Bearer token. * * @param request - The request object @@ -28,14 +30,16 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< } try { - const result = await upsertAccountSnapshot({ - account_id: validated.accountId, - ...(validated.snapshotId && { - snapshot_id: validated.snapshotId, - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), - }), - ...(validated.githubRepo && { github_repo: validated.githubRepo }), - }); + const result = validated.snapshotId + ? await upsertAccountSnapshot({ + account_id: validated.accountId, + snapshot_id: validated.snapshotId, + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + ...(validated.githubRepo && { github_repo: validated.githubRepo }), + }) + : await updateAccountSnapshot(validated.accountId, { + github_repo: validated.githubRepo, + }); if (result.error || !result.data) { return NextResponse.json( diff --git a/lib/supabase/account_snapshots/updateAccountSnapshot.ts b/lib/supabase/account_snapshots/updateAccountSnapshot.ts new file mode 100644 index 00000000..1027c2d4 --- /dev/null +++ b/lib/supabase/account_snapshots/updateAccountSnapshot.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; +import type { PostgrestError } from "@supabase/supabase-js"; + +interface UpdateAccountSnapshotResult { + data: Tables<"account_snapshots"> | null; + error: PostgrestError | null; +} + +/** + * Updates an existing account snapshot record. + * Use this for partial updates (e.g. setting github_repo without changing snapshot_id). + * + * @param accountId - The account ID to update + * @param params - The fields to update + * @returns The updated record or error + */ +export async function updateAccountSnapshot( + accountId: string, + params: TablesUpdate<"account_snapshots">, +): Promise { + const { data, error } = await supabase + .from("account_snapshots") + .update(params) + .eq("account_id", accountId) + .select("*") + .single(); + + return { data, error }; +} From 09b8c33c09aaed57b757607a00f98d132a123c20 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 15:45:49 -0500 Subject: [PATCH 08/11] feat: return full row from PATCH /api/sandboxes response Returns the complete account_snapshots record (account_id, snapshot_id, expires_at, github_repo, created_at) instead of just { success, snapshotId }. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/updateSnapshotPatchHandler.test.ts | 10 +++++++--- lib/sandbox/updateSnapshotPatchHandler.ts | 8 +------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 99e8dec1..6d956c58 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -46,7 +46,7 @@ describe("updateSnapshotPatchHandler", () => { expect(response.status).toBe(401); }); - it("returns 200 with success and snapshotId on successful upsert", async () => { + it("returns 200 with full row on successful upsert", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -59,6 +59,7 @@ describe("updateSnapshotPatchHandler", () => { snapshot_id: "snap_abc123", expires_at: "2025-01-01T00:00:00.000Z", created_at: "2024-01-01T00:00:00.000Z", + github_repo: null, }, error: null, }); @@ -69,8 +70,11 @@ describe("updateSnapshotPatchHandler", () => { expect(response.status).toBe(200); const json = await response.json(); expect(json).toEqual({ - success: true, - snapshotId: "snap_abc123", + account_id: "acc_123", + snapshot_id: "snap_abc123", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: null, }); }); diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index c3abc75e..79826198 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -48,13 +48,7 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< ); } - return NextResponse.json( - { - success: true, - snapshotId: result.data.snapshot_id, - }, - { status: 200, headers: getCorsHeaders() }, - ); + return NextResponse.json(result.data, { status: 200, headers: getCorsHeaders() }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update snapshot"; return NextResponse.json( From 38d5606f998af794709a6ee13aeb7570e579fa8d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 15:52:12 -0500 Subject: [PATCH 09/11] fix: return current row when no fields to update for consistent response When neither snapshotId nor github_repo is provided, fetches and returns the existing account_snapshots row instead of { success: true }, so the response shape is consistent regardless of input. Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 25 +++++++++++++++++-- lib/sandbox/updateSnapshotPatchHandler.ts | 7 +++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 6d956c58..d00ff823 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -6,6 +6,7 @@ import { updateSnapshotPatchHandler } from "../updateSnapshotPatchHandler"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/validateSnapshotPatchBody", () => ({ validateSnapshotPatchBody: vi.fn(), @@ -19,6 +20,10 @@ vi.mock("@/lib/supabase/account_snapshots/updateAccountSnapshot", () => ({ updateAccountSnapshot: vi.fn(), })); +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -165,20 +170,36 @@ describe("updateSnapshotPatchHandler", () => { expect(response.status).toBe(200); }); - it("skips upsert and returns 200 when no fields to update", async () => { + it("returns current row when no fields to update", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_existing", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", + }, + ]); const request = createMockRequest(); const response = await updateSnapshotPatchHandler(request); expect(upsertAccountSnapshot).not.toHaveBeenCalled(); + expect(selectAccountSnapshots).toHaveBeenCalledWith("acc_123"); expect(response.status).toBe(200); const json = await response.json(); - expect(json).toEqual({ success: true }); + expect(json).toEqual({ + account_id: "acc_123", + snapshot_id: "snap_existing", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", + }); }); it("returns 400 when upsertAccountSnapshot returns error", async () => { diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 79826198..531c3e30 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; /** * Handler for PATCH /api/sandboxes/snapshot. @@ -23,10 +24,8 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< } if (!validated.snapshotId && !validated.githubRepo) { - return NextResponse.json( - { success: true }, - { status: 200, headers: getCorsHeaders() }, - ); + const rows = await selectAccountSnapshots(validated.accountId); + return NextResponse.json(rows[0] ?? null, { status: 200, headers: getCorsHeaders() }); } try { From 75785147cf0bd0474c89038d08d6ae1e16e73029 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 15:54:12 -0500 Subject: [PATCH 10/11] refactor: always use upsert, remove updateAccountSnapshot Now that snapshot_id is nullable in Supabase, the upsert function works for all cases. Removes the separate updateAccountSnapshot function. Co-Authored-By: Claude Opus 4.5 --- .../updateSnapshotPatchHandler.test.ts | 21 ++++++------- lib/sandbox/updateSnapshotPatchHandler.ts | 23 ++++++-------- .../updateAccountSnapshot.ts | 30 ------------------- 3 files changed, 18 insertions(+), 56 deletions(-) delete mode 100644 lib/supabase/account_snapshots/updateAccountSnapshot.ts diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index d00ff823..39af95ab 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -5,7 +5,6 @@ import { NextResponse } from "next/server"; import { updateSnapshotPatchHandler } from "../updateSnapshotPatchHandler"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; -import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/validateSnapshotPatchBody", () => ({ @@ -16,10 +15,6 @@ vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ upsertAccountSnapshot: vi.fn(), })); -vi.mock("@/lib/supabase/account_snapshots/updateAccountSnapshot", () => ({ - updateAccountSnapshot: vi.fn(), -})); - vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ selectAccountSnapshots: vi.fn(), })); @@ -142,17 +137,17 @@ describe("updateSnapshotPatchHandler", () => { ); }); - it("uses updateAccountSnapshot when only github_repo is provided", async () => { + it("upserts with github_repo when only github_repo is provided", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", githubRepo: "https://github.com/org/repo", }); - vi.mocked(updateAccountSnapshot).mockResolvedValue({ + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ data: { account_id: "acc_123", - snapshot_id: "snap_existing", + snapshot_id: null, expires_at: "2025-01-01T00:00:00.000Z", created_at: "2024-01-01T00:00:00.000Z", github_repo: "https://github.com/org/repo", @@ -163,10 +158,12 @@ describe("updateSnapshotPatchHandler", () => { const request = createMockRequest(); const response = await updateSnapshotPatchHandler(request); - expect(upsertAccountSnapshot).not.toHaveBeenCalled(); - expect(updateAccountSnapshot).toHaveBeenCalledWith("acc_123", { - github_repo: "https://github.com/org/repo", - }); + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_123", + github_repo: "https://github.com/org/repo", + }), + ); expect(response.status).toBe(200); }); diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index 531c3e30..ecf79afd 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -3,19 +3,16 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; -import { updateAccountSnapshot } from "@/lib/supabase/account_snapshots/updateAccountSnapshot"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; /** * Handler for PATCH /api/sandboxes/snapshot. * * Updates the snapshot ID and/or github_repo for an account. - * Uses upsert when snapshot_id is provided (may create a new record), - * or update when only github_repo is provided (modifies existing record). * Requires authentication via x-api-key header or Authorization Bearer token. * * @param request - The request object - * @returns A NextResponse with the updated snapshot ID or error + * @returns A NextResponse with the updated account snapshot row or error */ export async function updateSnapshotPatchHandler(request: NextRequest): Promise { const validated = await validateSnapshotPatchBody(request); @@ -29,16 +26,14 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< } try { - const result = validated.snapshotId - ? await upsertAccountSnapshot({ - account_id: validated.accountId, - snapshot_id: validated.snapshotId, - expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), - ...(validated.githubRepo && { github_repo: validated.githubRepo }), - }) - : await updateAccountSnapshot(validated.accountId, { - github_repo: validated.githubRepo, - }); + const result = await upsertAccountSnapshot({ + account_id: validated.accountId, + ...(validated.snapshotId && { + snapshot_id: validated.snapshotId, + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }), + ...(validated.githubRepo && { github_repo: validated.githubRepo }), + }); if (result.error || !result.data) { return NextResponse.json( diff --git a/lib/supabase/account_snapshots/updateAccountSnapshot.ts b/lib/supabase/account_snapshots/updateAccountSnapshot.ts deleted file mode 100644 index 1027c2d4..00000000 --- a/lib/supabase/account_snapshots/updateAccountSnapshot.ts +++ /dev/null @@ -1,30 +0,0 @@ -import supabase from "../serverClient"; -import type { Tables, TablesUpdate } from "@/types/database.types"; -import type { PostgrestError } from "@supabase/supabase-js"; - -interface UpdateAccountSnapshotResult { - data: Tables<"account_snapshots"> | null; - error: PostgrestError | null; -} - -/** - * Updates an existing account snapshot record. - * Use this for partial updates (e.g. setting github_repo without changing snapshot_id). - * - * @param accountId - The account ID to update - * @param params - The fields to update - * @returns The updated record or error - */ -export async function updateAccountSnapshot( - accountId: string, - params: TablesUpdate<"account_snapshots">, -): Promise { - const { data, error } = await supabase - .from("account_snapshots") - .update(params) - .eq("account_id", accountId) - .select("*") - .single(); - - return { data, error }; -} From 52d0bbd178035e216c34153c7721299126ebbd9e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Feb 2026 16:34:00 -0500 Subject: [PATCH 11/11] fix: use snake_case keys in upsertAccountSnapshot test to match TablesInsert type --- .../__tests__/upsertAccountSnapshot.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts index ce8e8a80..de71fd24 100644 --- a/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts +++ b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts @@ -31,8 +31,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: mockData, error: null }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_abc123", + account_id: "account-456", + snapshot_id: "snap_abc123", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -56,8 +56,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: mockData, error: null }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_new789", + account_id: "account-456", + snapshot_id: "snap_new789", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -69,8 +69,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: null, error: mockError }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_abc123", + account_id: "account-456", + snapshot_id: "snap_abc123", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -79,14 +79,15 @@ describe("upsertAccountSnapshot", () => { it("returns error when account_id foreign key constraint fails", async () => { const mockError = { - message: 'insert or update on table "account_snapshots" violates foreign key constraint', + message: + 'insert or update on table "account_snapshots" violates foreign key constraint', code: "23503", }; mockSingle.mockResolvedValue({ data: null, error: mockError }); const result = await upsertAccountSnapshot({ - accountId: "non-existent-account", - snapshotId: "snap_abc123", + account_id: "non-existent-account", + snapshot_id: "snap_abc123", }); expect(result).toEqual({ data: null, error: mockError });