Harden deploy gates, prod AUTH_SECRET, and Sentry error reporting.
Local Build and Deploy / deploy (push) Successful in 1m42s
Local Build and Deploy / deploy (push) Successful in 1m42s
Align onlyBuiltDependencies with the workspace, fail fast without AUTH_SECRET in production, and delete catalog_items via VARCHAR-safe SQL so page deletes do not leave orphans. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
b9c6001f08
commit
6b884ad25a
12 files changed
+128
-50
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError, type z } from "zod";
|
||||
import { reportError } from "@/lib/report-error";
|
||||
|
||||
/** Throwable API error with HTTP status code */
|
||||
export class ApiError extends Error {
|
||||
@@ -46,11 +47,6 @@ export function handleApiError(error: unknown): Response {
|
||||
) {
|
||||
return apiError("Not found", 404);
|
||||
}
|
||||
console.error(
|
||||
"[API error]",
|
||||
error instanceof Error
|
||||
? { message: error.message, name: error.name }
|
||||
: error,
|
||||
);
|
||||
reportError(error, "API error");
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
@@ -12,4 +12,26 @@ describe("production deploy workflow", () => {
|
||||
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
|
||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
||||
});
|
||||
|
||||
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
|
||||
expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES");
|
||||
});
|
||||
|
||||
it("runs typecheck and tests before build", () => {
|
||||
expect(workflow).toContain("pnpm typecheck");
|
||||
expect(workflow).toContain("pnpm test");
|
||||
const typecheckAt = workflow.indexOf("pnpm typecheck");
|
||||
const testAt = workflow.indexOf("pnpm test");
|
||||
const buildAt = workflow.indexOf("pnpm build");
|
||||
expect(typecheckAt).toBeGreaterThan(-1);
|
||||
expect(testAt).toBeGreaterThan(typecheckAt);
|
||||
expect(buildAt).toBeGreaterThan(testAt);
|
||||
});
|
||||
|
||||
it("exports APP_VERSION from git for Sentry releases", () => {
|
||||
expect(workflow).toContain('export APP_VERSION="$(git rev-parse --short HEAD)"');
|
||||
expect(workflow).toContain(
|
||||
'export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { reportError } from "@/lib/report-error";
|
||||
import {
|
||||
DatabaseError,
|
||||
ForbiddenError,
|
||||
@@ -247,11 +248,6 @@ export function handleActionError(error: unknown): ActionFailure {
|
||||
return fail("Not found");
|
||||
}
|
||||
|
||||
console.error(
|
||||
"[Action error]",
|
||||
error instanceof Error
|
||||
? { message: error.message, name: error.name }
|
||||
: error,
|
||||
);
|
||||
reportError(error, "Action error");
|
||||
return fail("Internal server error");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* Log unexpected errors and forward them to Sentry when a DSN is configured.
|
||||
* Domain errors (validation, auth, etc.) should NOT go through here.
|
||||
*/
|
||||
export function reportError(error: unknown, context = "Unhandled error"): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(context, {
|
||||
err: message,
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
});
|
||||
|
||||
if (process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,19 @@ export async function movePage(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete catalog_items for the given page ids.
|
||||
* Habbo DBs often store page_id as VARCHAR; Prisma Int deleteMany misses rows.
|
||||
*/
|
||||
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const idStrs = pageIds.map(String);
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page with cascade or reparent mode.
|
||||
*/
|
||||
@@ -223,7 +236,7 @@ export async function deletePage(
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
||||
await deleteCatalogItemsByPageIds([pageId]);
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
|
||||
return { deletedPages: 1, movedChildren: result.count };
|
||||
@@ -249,7 +262,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
|
||||
await deleteCatalogItemsByPageIds(toDelete);
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user