diff --git a/docs/developer-api.md b/docs/developer-api.md index d230e733..7f2f0551 100644 --- a/docs/developer-api.md +++ b/docs/developer-api.md @@ -33,9 +33,48 @@ Returns a ranked page of published results: } ``` +### `GET /api/v1/catalog` + +Downloads one shared, lightweight snapshot of every published gear record. It is +slug-sorted and contains only `name`, `slug`, `brandName`, `gearType`, +`thumbnailUrl`, `releaseDate`, `releaseDatePrecision`, `announcedDate`, and +`announceDatePrecision` for each item. It deliberately excludes specifications, +aliases, samples, colourways, prices, IDs, and audit metadata. + +```json +{ + "version": "sha256-...", + "generatedAt": "2026-07-15T15:00:00.000Z", + "itemCount": 123, + "data": [] +} +``` + +`version` is a SHA-256 hash of the deterministic `data` array. The endpoint +returns the same value as a quoted `ETag`; `generatedAt` is fixed for the cached +snapshot and does not affect the version. Send a standard `If-None-Match` header +to revalidate a locally stored snapshot. Matching strong or weak tags, +comma-separated tags, and `*` receive `304 Not Modified` with `ETag`, request, +rate-limit, and cache headers. A `200` response also includes the normal +`meta.requestId`. + +```sh +curl -i -H "Authorization: Bearer sharply_live_..." \ + -H 'If-None-Match: "sha256-..."' \ + https://www.sharplyphoto.com/api/v1/catalog +``` + +The endpoint sends `Cache-Control: private, max-age=0, must-revalidate` and +`Vary: Authorization, If-None-Match`. Its shared server snapshot is invalidated +when published catalog membership or an included catalog field changes. + ### `GET /api/v1/gear/:slug` -Returns the complete currently publishable catalog record, including available related specifications, aliases, media, samples, and colourways. Database primary keys and audit timestamps are omitted at every nesting level. Hidden and rumored gear return `404`. +Returns the complete currently publishable catalog record, including available related specifications, aliases, media, and colourways. The response keeps its established field names: `brands`, flattened image URL fields, `regionalAliases`, and `colorways`. + +The response is an explicit public allowlist. It includes catalog identity, release and price data, dimensions, public links, approved relation fields, and type-specific specification values. `mounts` contains `{ value, shortName }` records from the gear-to-mount relationship. `cameraSpecs.sensorFormat` and `lensSpecs.imageCircle` / `fixedLensSpecs.imageCircle` contain `{ slug, name, cropFactor }` when the referenced sensor format exists, otherwise `null`. + +Primary and foreign keys, audit timestamps, search helpers, publication workflow state, genre tags, raw samples, selected colourway IDs, internal notes, video-mode matrices, and flexible `extra` JSON are never returned. Adding a public field requires an intentional serializer, contract, and documentation update. Hidden and rumored gear return `404`. ### `GET /api/v1/specs` diff --git a/messages/de.json b/messages/de.json index d97a8288..0f03514c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Beispielanfrage", "exampleCurl": "cURL", diff --git a/messages/en.json b/messages/en.json index 14e74811..0c3fa1d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Example request", "exampleCurl": "cURL", diff --git a/messages/es.json b/messages/es.json index d61f5732..c851bcda 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Solicitud de ejemplo", "exampleCurl": "cURL", diff --git a/messages/fr.json b/messages/fr.json index a1ecbad3..1868bbfc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Exemple de requête", "exampleCurl": "cURL", diff --git a/messages/it.json b/messages/it.json index 482cd43f..5d951c4e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Esempio di richiesta", "exampleCurl": "cURL", diff --git a/messages/ja.json b/messages/ja.json index 58188a61..aea42f1e 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "リクエスト例", "exampleCurl": "cURL", diff --git a/messages/ms.json b/messages/ms.json index b3a6ab67..5aa4b54d 100644 --- a/messages/ms.json +++ b/messages/ms.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "Contoh permintaan", "exampleCurl": "cURL", diff --git a/messages/zh.json b/messages/zh.json index be2fb049..dbe9d443 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1893,8 +1893,12 @@ "endpointsTitle": "Endpoints", "searchTitle": "Search gear", "searchDescription": "Returns a ranked page of published gear matching a query.", + "catalogTitle": "Download catalog", + "catalogDescription": "Returns a lightweight, slug-sorted snapshot of all published gear for local search prefilling.", + "catalogConditionalRequest": "Store the ETag and send it as If-None-Match on the next request. A matching snapshot returns 304 Not Modified.", "gearTitle": "Get gear detail", "gearDescription": "Returns the complete published catalog record and its available related data.", + "gearTaxonomyNote": "Mounts use value and shortName. Camera sensorFormat and lens/fixed-lens imageCircle use slug, name, and cropFactor; a missing sensor format is returned as null.", "parametersTitle": "Parameters", "exampleRequest": "请求示例", "exampleCurl": "cURL", diff --git a/src/app/[locale]/(pages)/developer/docs/page.tsx b/src/app/[locale]/(pages)/developer/docs/page.tsx index e80915a0..0f25e11f 100644 --- a/src/app/[locale]/(pages)/developer/docs/page.tsx +++ b/src/app/[locale]/(pages)/developer/docs/page.tsx @@ -186,6 +186,63 @@ const { data, pagination } = await response.json();`} +
+
+ GET + /api/v1/catalog +
+

+ {t("catalogTitle")} +

+

+ {t("catalogDescription")} +

+
+

{t("exampleRequest")}

+ +

{t("responseTitle")}

+ {`{ + "version": "sha256-…", + "generatedAt": "2026-07-15T15:00:00.000Z", + "itemCount": 1, + "data": [{ + "name": "Nikon Z6III", + "slug": "nikon-z6iii", + "brandName": "Nikon", + "gearType": "CAMERA", + "thumbnailUrl": "https://…", + "releaseDate": "2024-06-24T00:00:00.000Z", + "releaseDatePrecision": "DAY", + "announcedDate": "2024-06-17T00:00:00.000Z", + "announceDatePrecision": "DAY" + }] +}`} + {`curl -i \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H 'If-None-Match: "sha256-…"' \\ + "https://www.sharplyphoto.com/api/v1/catalog"`} +

+ {t("catalogConditionalRequest")} +

+
+
+
GET @@ -210,9 +267,9 @@ const { data, pagination } = await response.json();`} {t("responseTitle")} {`{ "data": { - "slug": "nikon-z6-iii", - "name": "Nikon Z6 III", + "slug": "nikon-z6iii", + "name": "Nikon Z6III", "gearType": "CAMERA", - "cameraSpecs": { "resolutionMp": "24.5" } + "mounts": [{ "value": "z-nikon", "shortName": "z" }], + "cameraSpecs": { + "sensorFormat": { + "slug": "full-frame", + "name": "Full-frame", + "cropFactor": "1.00" + }, + "resolutionMp": "24.50" + } } }`} +

+ {t("gearTaxonomyNote")} +

diff --git a/src/app/[locale]/(pages)/gear/[slug]/@edit/(.)edit/page.tsx b/src/app/[locale]/(pages)/gear/[slug]/@edit/(.)edit/page.tsx index 1485beab..38e77553 100644 --- a/src/app/[locale]/(pages)/gear/[slug]/@edit/(.)edit/page.tsx +++ b/src/app/[locale]/(pages)/gear/[slug]/@edit/(.)edit/page.tsx @@ -1,5 +1,6 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { EditAlreadyPendingRedirect } from "~/app/[locale]/(pages)/gear/_components/edit-already-pending-redirect"; import { EditGearModal } from "~/app/[locale]/(pages)/gear/_components/edit-gear/edit-gear-modal"; import { auth } from "~/auth"; import { requireRole } from "~/lib/auth/auth-helpers"; @@ -32,12 +33,15 @@ export default async function EditGearModalPage({ redirect(`/auth/signin?callbackUrl=${encodeURIComponent(editUrl)}`); } - // Prevent duplicate submissions: if user already has a pending edit for this gear, - // redirect back to gear page with flags to show a toast + // Prevent duplicate submissions: if user already has a pending edit for this + // gear, render a client component that navigates back and shows a toast. + // NOTE: Do NOT use server-side `redirect()` here — it causes a runaway + // request loop because Next.js re-fetches parallel-route interception slots + // on every prefetch/soft-nav, and each fetch triggers a new redirect. if (session?.user) { const pendingId = await fetchPendingEditId(slug).catch(() => null); if (pendingId) { - redirect(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`); + return ; } } diff --git a/src/app/[locale]/(pages)/gear/[slug]/edit/page.tsx b/src/app/[locale]/(pages)/gear/[slug]/edit/page.tsx index 4aedf1a9..31ba3191 100644 --- a/src/app/[locale]/(pages)/gear/[slug]/edit/page.tsx +++ b/src/app/[locale]/(pages)/gear/[slug]/edit/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { EditAlreadyPendingRedirect } from "~/app/[locale]/(pages)/gear/_components/edit-already-pending-redirect"; import EditGearClient from "~/app/[locale]/(pages)/gear/_components/edit-gear/edit-gear-page-client"; import { auth } from "~/auth"; import { requireRole } from "~/lib/auth/auth-helpers"; @@ -49,11 +50,15 @@ export default async function EditGearPage({ redirect(`/auth/signin?callbackUrl=${encodeURIComponent(editUrl)}`); } - // Prevent duplicate submissions: redirect back if user already has a pending edit + // Prevent duplicate submissions: if user already has a pending edit, + // render a client component that navigates back and shows a toast. + // NOTE: Do NOT use server-side `redirect()` here — in the intercepting + // route variant this causes a runaway request loop; keep both routes + // consistent to avoid the same class of issue on hard navigations. if (session?.user) { const pendingId = await fetchPendingEditId(slug).catch(() => null); if (pendingId) { - redirect(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`); + return ; } } diff --git a/src/app/[locale]/(pages)/gear/[slug]/page.tsx b/src/app/[locale]/(pages)/gear/[slug]/page.tsx index aca4e343..c786eb3b 100644 --- a/src/app/[locale]/(pages)/gear/[slug]/page.tsx +++ b/src/app/[locale]/(pages)/gear/[slug]/page.tsx @@ -60,6 +60,7 @@ import { } from "~/server/popularity/service"; import { AiReviewBanner } from "../_components/ai-review-banner"; import { CreatorVideosSection } from "../_components/creator-videos-section"; +import { EditAlreadyPendingToast } from "../_components/edit-already-pending-toast"; import { EditAppliedToast } from "../_components/edit-applied-toast"; import { GearAlternativesSection } from "../_components/gear-alternatives-section"; import { buildGearBreadcrumbItems } from "../_components/gear-breadcrumb-items"; @@ -256,6 +257,7 @@ export default async function GearPage({ params }: GearPageProps) {
+ { + router.replace(`/gear/${slug}?editAlreadyPending=1&id=${pendingId}`, { + scroll: false, + }); + }, [router, slug, pendingId]); + + return null; +} diff --git a/src/app/[locale]/(pages)/gear/_components/edit-already-pending-toast.tsx b/src/app/[locale]/(pages)/gear/_components/edit-already-pending-toast.tsx new file mode 100644 index 00000000..a0396f59 --- /dev/null +++ b/src/app/[locale]/(pages)/gear/_components/edit-already-pending-toast.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +/** + * Reads the `editAlreadyPending` search param set by the edit route redirect, + * shows a user-facing toast, and cleans up the URL. + * + * Mirrors the pattern used by `EditAppliedToast` for the `editApplied` param. + */ +export function EditAlreadyPendingToast() { + const rawPathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (searchParams.get("editAlreadyPending") !== "1") return; + + toast.info("You already have a pending edit for this item.", { + description: "Your previous suggestion is still under review.", + }); + + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete("editAlreadyPending"); + nextParams.delete("id"); + + router.replace( + nextParams.size > 0 + ? `${rawPathname}?${nextParams.toString()}` + : rawPathname, + { scroll: false }, + ); + }, [rawPathname, router, searchParams]); + + return null; +} diff --git a/src/app/api/v1/catalog/route.ts b/src/app/api/v1/catalog/route.ts new file mode 100644 index 00000000..1fe7d5ac --- /dev/null +++ b/src/app/api/v1/catalog/route.ts @@ -0,0 +1,27 @@ +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { + createDeveloperCatalogEtag, + getDeveloperCatalogSnapshot, + matchesDeveloperCatalogEtag, +} from "~/server/developer-api/service"; + +const CATALOG_CACHE_HEADERS = { + "Cache-Control": "private, max-age=0, must-revalidate", + Vary: "Authorization, If-None-Match", +}; + +export async function GET(request: Request) { + return runDeveloperApiRequest(request, "catalog", async () => { + const snapshot = await getDeveloperCatalogSnapshot(); + const etag = createDeveloperCatalogEtag(snapshot.version); + const headers = { ...CATALOG_CACHE_HEADERS, ETag: etag }; + + if ( + matchesDeveloperCatalogEtag(request.headers.get("if-none-match"), etag) + ) { + return { response: new Response(null, { status: 304, headers }) }; + } + + return { ...snapshot, headers }; + }); +} diff --git a/src/components/layout/header-client.tsx b/src/components/layout/header-client.tsx index 95484482..e57e9868 100644 --- a/src/components/layout/header-client.tsx +++ b/src/components/layout/header-client.tsx @@ -5,7 +5,7 @@ import { track } from "@vercel/analytics"; import { LayoutDashboard, LogIn, Menu } from "lucide-react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import Logo from "public/logo"; import type { HeaderUser, @@ -70,7 +70,22 @@ export default function HeaderClient({ const signInHref = `${signInBaseHref}?callbackUrl=${encodeURIComponent(callbackUrl)}`; // Client-side auth state — avoids headers() call on the server during ISR. - const { data: session, isPending: isSessionPending } = useSession(); + const { + data: session, + isPending: isSessionPending, + refetch: refetchSession, + } = useSession(); + const hasRefetchedSession = useRef(false); + + // Developer access is granted by an admin outside of Better Auth's cached + // session payload. Refresh it once so a newly granted user sees the portal + // without waiting for the compact session cookie to expire. + useEffect(() => { + if (hasRefetchedSession.current) return; + hasRefetchedSession.current = true; + void refetchSession({ query: { disableCookieCache: true } }); + }, [refetchSession]); + const user: HeaderUser = session?.user ? { id: session.user.id, diff --git a/src/server/admin/gear/service.ts b/src/server/admin/gear/service.ts index 8fe66863..eb7c4d46 100644 --- a/src/server/admin/gear/service.ts +++ b/src/server/admin/gear/service.ts @@ -1,16 +1,23 @@ import "server-only"; -import { and,eq,inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { nanoid } from "nanoid"; import { requireRole } from "~/lib/auth/auth-helpers"; import { GEAR_PUBLICATION_STATES } from "~/lib/gear/publication-state"; import { buildGearSearchName } from "~/lib/gear/naming"; -import { GEAR_REGIONS,type GearRegion } from "~/lib/gear/region"; +import { GEAR_REGIONS, type GearRegion } from "~/lib/gear/region"; import { shouldBlockFuzzyResults } from "~/lib/utils/gear-creation"; import { getSessionOrThrow } from "~/server/auth"; import { db } from "~/server/db"; -import { auditLogs,brands,gear,gearAliases,gearEdits } from "~/server/db/schema"; -import { clearImageRequestsForGear,getGearIdBySlug } from "~/server/gear/data"; +import { + auditLogs, + brands, + gear, + gearAliases, + gearEdits, +} from "~/server/db/schema"; +import { invalidateDeveloperApiCatalogCache } from "~/server/developer-api/cache"; +import { clearImageRequestsForGear, getGearIdBySlug } from "~/server/gear/data"; import { checkGearCreationData, createGearData, @@ -35,7 +42,7 @@ import { type UpdateGearPublicationStateResult, } from "./data"; -export type { AdminGearTableRow,GearCreationParams } from "./data"; +export type { AdminGearTableRow, GearCreationParams } from "./data"; function assertRearViewSupported(gearType: string) { if (gearType === "LENS") { @@ -115,6 +122,8 @@ export async function createGearAdmin( gearId: created.id, }); + invalidateDeveloperApiCatalogCache(); + return created; } @@ -137,7 +146,9 @@ export async function updateGearPublicationStateService(params: { }); } - return updateGearPublicationStateData(params); + const updated = await updateGearPublicationStateData(params); + invalidateDeveloperApiCatalogCache(); + return updated; } export async function fetchAdminGearItems( @@ -172,6 +183,8 @@ export async function renameGearService(params: { }); } catch {} + invalidateDeveloperApiCatalogCache(); + return updated; } @@ -304,6 +317,8 @@ export async function setGearThumbnailService(params: { thumbnailUrl, ogImageUrl, }); + const thumbnailChanged = + (currentGear.thumbnailUrl ?? null) !== updated.thumbnailUrl; if (thumbnailUrl) { // Clear outstanding image requests once an image is provided @@ -348,6 +363,8 @@ export async function setGearThumbnailService(params: { } } catch {} + if (thumbnailChanged) invalidateDeveloperApiCatalogCache(); + return updated; } @@ -524,9 +541,7 @@ export async function setGearRearViewService(params: { | "GEAR_REAR_VIEW_REMOVE"; if (rearViewUrl) { // Setting a new rear view - action = hadRearView - ? "GEAR_REAR_VIEW_REPLACE" - : "GEAR_REAR_VIEW_UPLOAD"; + action = hadRearView ? "GEAR_REAR_VIEW_REPLACE" : "GEAR_REAR_VIEW_UPLOAD"; } else { // Removing rear view action = "GEAR_REAR_VIEW_REMOVE"; @@ -574,7 +589,7 @@ export async function deleteGearService( throw Object.assign(new Error("Unauthorized"), { status: 401 }); } - return db.transaction(async (tx) => { + const deleted = await db.transaction(async (tx) => { const existing = await tx .select({ id: gear.id }) .from(gear) @@ -601,4 +616,7 @@ export async function deleteGearService( return deleteGearData(gearId, tx); }); + + invalidateDeveloperApiCatalogCache(); + return deleted; } diff --git a/src/server/admin/proposals/service.ts b/src/server/admin/proposals/service.ts index 27cc290c..6184b92c 100644 --- a/src/server/admin/proposals/service.ts +++ b/src/server/admin/proposals/service.ts @@ -3,6 +3,7 @@ import "server-only"; import { requireRole } from "~/lib/auth/auth-helpers"; import { getSessionOrThrow } from "~/server/auth"; import { evaluateForEvent } from "~/server/badges/service"; +import { invalidateDeveloperApiCatalogCache } from "~/server/developer-api/cache"; import { createNotification } from "~/server/notifications/service"; import type { GearEditProposal } from "~/types/gear"; import { @@ -38,6 +39,28 @@ type ProposalGroups = { resolvedRecentCount: number; }; +const CATALOG_CORE_FIELDS = new Set([ + "name", + "slug", + "brandId", + "gearType", + "thumbnailUrl", + "releaseDate", + "releaseDatePrecision", + "announcedDate", + "announceDatePrecision", + "publicationState", +]); + +function proposalAffectsDeveloperCatalog(payload: unknown) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const core = (payload as { core?: unknown }).core; + if (!core || typeof core !== "object" || Array.isArray(core)) return false; + return Object.keys(core).some((key) => CATALOG_CORE_FIELDS.has(key)); +} + function groupProposals(proposals: EnrichedProposal[]): ProposalGroup[] { const groupedProposals = proposals.reduce( (acc, proposal) => { @@ -200,6 +223,10 @@ export async function applyTrustedContributorProposalApproval( filteredPayload, ); + if (proposalAffectsDeveloperCatalog(filteredPayload ?? proposal.payload)) { + invalidateDeveloperApiCatalogCache(); + } + await notifyContributorOfApprovedGearEdit({ gearId: proposal.gearId, proposalId: proposal.id, @@ -237,6 +264,10 @@ export async function approveProposal( filteredPayload, ); + if (proposalAffectsDeveloperCatalog(filteredPayload ?? proposal.payload)) { + invalidateDeveloperApiCatalogCache(); + } + await notifyContributorOfApprovedGearEdit({ gearId: proposal.gearId, proposalId: proposal.id, diff --git a/src/server/developer-api/cache.ts b/src/server/developer-api/cache.ts new file mode 100644 index 00000000..b159c18c --- /dev/null +++ b/src/server/developer-api/cache.ts @@ -0,0 +1,9 @@ +import "server-only"; + +import { revalidateTag } from "next/cache"; +import { DEVELOPER_API_CATALOG_CACHE_TAG } from "./constants"; + +/** Invalidates the shared snapshot after a public catalog field changes. */ +export function invalidateDeveloperApiCatalogCache() { + revalidateTag(DEVELOPER_API_CATALOG_CACHE_TAG, { expire: 0 }); +} diff --git a/src/server/developer-api/constants.ts b/src/server/developer-api/constants.ts index 4b6fa6c3..d75ed77b 100644 --- a/src/server/developer-api/constants.ts +++ b/src/server/developer-api/constants.ts @@ -8,6 +8,9 @@ export const DEVELOPER_API_ENDPOINTS = [ "search", "suggestions", "gear", + "catalog", ] as const; +export const DEVELOPER_API_CATALOG_CACHE_TAG = "developer-api-catalog"; + export type DeveloperApiEndpoint = (typeof DEVELOPER_API_ENDPOINTS)[number]; diff --git a/src/server/developer-api/data.ts b/src/server/developer-api/data.ts index 3b9ef98e..9e8add7f 100644 --- a/src/server/developer-api/data.ts +++ b/src/server/developer-api/data.ts @@ -1,17 +1,110 @@ import "server-only"; -import { and, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; +import { asc, and, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; +import { GEAR_PUBLICATION_STATES } from "~/lib/gear/publication-state"; import { db } from "~/server/db"; import { + brands, developerApiKeys, developerApiRateLimitBuckets, developerApiUsageDaily, + gear, + gearMounts, + mounts, + sensorFormats, users, } from "~/server/db/schema"; import type { DeveloperApiEndpoint } from "./constants"; export type DeveloperApiKeyRow = typeof developerApiKeys.$inferSelect; +export type DeveloperApiMount = { + value: string; + shortName: string | null; +}; + +export type DeveloperApiSensorFormat = { + slug: string; + name: string; + cropFactor: string; +}; + +export type DeveloperApiCatalogItem = { + name: string; + slug: string; + brandName: string; + gearType: string; + thumbnailUrl: string | null; + releaseDate: Date | null; + releaseDatePrecision: string | null; + announcedDate: Date | null; + announceDatePrecision: string | null; +}; + +/** A deliberately narrow, slug-sorted read for the downloadable API catalog. */ +export async function fetchDeveloperCatalogData(): Promise< + DeveloperApiCatalogItem[] +> { + return db + .select({ + name: gear.name, + slug: gear.slug, + brandName: brands.name, + gearType: gear.gearType, + thumbnailUrl: gear.thumbnailUrl, + releaseDate: gear.releaseDate, + releaseDatePrecision: gear.releaseDatePrecision, + announcedDate: gear.announcedDate, + announceDatePrecision: gear.announceDatePrecision, + }) + .from(gear) + .innerJoin(brands, eq(gear.brandId, brands.id)) + .where(eq(gear.publicationState, GEAR_PUBLICATION_STATES.PUBLISHED)) + .orderBy(asc(gear.slug)); +} + +export async function fetchDeveloperGearMountsData( + gearId: string, +): Promise { + return db + .select({ + value: mounts.value, + shortName: mounts.shortName, + }) + .from(gearMounts) + .innerJoin(mounts, eq(gearMounts.mountId, mounts.id)) + .where(eq(gearMounts.gearId, gearId)) + .orderBy(asc(mounts.value)); +} + +export async function fetchDeveloperSensorFormatsData( + formatIds: Array, +): Promise> { + const ids = [...new Set(formatIds.filter((id): id is string => !!id))]; + if (!ids.length) return new Map(); + + const rows = await db + .select({ + id: sensorFormats.id, + slug: sensorFormats.slug, + name: sensorFormats.name, + cropFactor: sensorFormats.cropFactor, + }) + .from(sensorFormats) + .where(inArray(sensorFormats.id, ids)); + + return new Map( + rows.map((format) => [ + format.id, + { + slug: format.slug, + name: format.name, + cropFactor: format.cropFactor, + }, + ]), + ); +} + export async function findUsableApiKeyByHash(keyHash: string) { const rows = await db .select({ diff --git a/src/server/developer-api/http.ts b/src/server/developer-api/http.ts index 987e602b..c8202016 100644 --- a/src/server/developer-api/http.ts +++ b/src/server/developer-api/http.ts @@ -15,12 +15,22 @@ import { recordDeveloperApiUsage, } from "./service"; -type SuccessfulResponse = { +type SuccessfulJsonResponse = { data: unknown; pagination?: unknown; headers?: Record; + response?: never; + [key: string]: unknown; }; +type SuccessfulResponse = SuccessfulJsonResponse | { response: Response }; + +function isNativeResponse( + response: SuccessfulResponse, +): response is { response: Response } { + return response.response instanceof Response; +} + function buildRateLimitHeaders(params?: { remaining: number; resetAt: Date }) { if (!params) return undefined; return { @@ -96,6 +106,17 @@ export async function runDeveloperApiRequest( const response = await handler(); await recordUsageBestEffort({ apiKeyId: credential.apiKeyId, endpoint }); + + if (isNativeResponse(response)) { + response.response.headers.set("X-Request-Id", requestId); + for (const [name, value] of Object.entries( + buildRateLimitHeaders(rateLimit) ?? {}, + )) { + response.response.headers.set(name, value); + } + return response.response; + } + const { headers, ...body } = response; return NextResponse.json( { ...body, meta: { requestId } }, diff --git a/src/server/developer-api/serializers.ts b/src/server/developer-api/serializers.ts index e8f44c71..8a41aa7a 100644 --- a/src/server/developer-api/serializers.ts +++ b/src/server/developer-api/serializers.ts @@ -4,7 +4,25 @@ if (process.env.NEXT_RUNTIME) { import type { SearchResponse } from "~/types/search-results"; import type { Suggestion } from "~/types/search"; -import type { GearItem } from "~/types/gear"; +import type { + AfAreaMode, + AnalogCameraSpecs, + Brand, + CameraCardSlot, + GearAlias, + GearColorway, +} from "~/types/gear"; +import type { + DeveloperApiCatalogItem, + DeveloperApiMount, + DeveloperApiSensorFormat, +} from "./data"; +import type { + DeveloperApiCameraSpecs, + DeveloperApiFixedLensSpecs, + DeveloperApiGear, + DeveloperApiLensSpecs, +} from "./service"; type JsonValue = | string @@ -14,16 +32,7 @@ type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -const DEVELOPER_API_GEAR_METADATA_KEYS = new Set([ - "id", - "createdAt", - "updatedAt", -]); - -function serializeJsonWithOmittedKeys( - value: unknown, - omittedKeys?: ReadonlySet, -): JsonValue { +function serializeJsonValue(value: unknown): JsonValue { if ( value === null || typeof value === "string" || @@ -34,13 +43,13 @@ function serializeJsonWithOmittedKeys( } if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) { - return value.map((item) => serializeJsonWithOmittedKeys(item, omittedKeys)); + return value.map((item) => serializeJsonValue(item)); } if (typeof value === "object") { const serialized: Record = {}; for (const [key, nestedValue] of Object.entries(value)) { - if (nestedValue === undefined || omittedKeys?.has(key)) continue; - serialized[key] = serializeJsonWithOmittedKeys(nestedValue, omittedKeys); + if (nestedValue === undefined) continue; + serialized[key] = serializeJsonValue(nestedValue); } return serialized; } @@ -50,7 +59,304 @@ function serializeJsonWithOmittedKeys( /** Convert database values to a JSON-only public API representation. */ export function serializeJson(value: unknown): JsonValue { - return serializeJsonWithOmittedKeys(value); + return serializeJsonValue(value); +} + +function serializeDate(value: Date | string | null | undefined) { + if (value === null || value === undefined) return null; + return value instanceof Date ? value.toISOString() : value; +} + +/** The complete, intentionally lightweight allowlist for catalog downloads. */ +export function serializeDeveloperCatalogData( + items: DeveloperApiCatalogItem[], +) { + return items.map((item) => ({ + name: item.name, + slug: item.slug, + brandName: item.brandName, + gearType: item.gearType, + thumbnailUrl: item.thumbnailUrl, + releaseDate: serializeDate(item.releaseDate), + releaseDatePrecision: item.releaseDatePrecision, + announcedDate: serializeDate(item.announcedDate), + announceDatePrecision: item.announceDatePrecision, + })); +} + +function serializeStringArray(value: unknown) { + if (!Array.isArray(value)) return null; + return value.filter((item): item is string => typeof item === "string"); +} + +function serializeFpsPair(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const pair = value as Record; + const raw = typeof pair.raw === "number" ? pair.raw : undefined; + const jpg = typeof pair.jpg === "number" ? pair.jpg : undefined; + if (raw === undefined && jpg === undefined) return undefined; + return { raw, jpg }; +} + +function serializeMaxFpsByShutter(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const shutters = value as Record; + return { + mechanical: serializeFpsPair(shutters.mechanical), + efc: serializeFpsPair(shutters.efc), + electronic: serializeFpsPair(shutters.electronic), + }; +} + +function serializeBrand(brand: Brand | null | undefined) { + if (!brand) return null; + return { + name: brand.name, + slug: brand.slug, + }; +} + +function serializeAlias(alias: GearAlias) { + return { + region: alias.region, + name: alias.name, + }; +} + +function serializeAliases(aliases: GearAlias[] | null | undefined) { + return (aliases ?? []).map(serializeAlias); +} + +function serializeMedia(item: DeveloperApiGear) { + return { + thumbnailUrl: item.thumbnailUrl, + ogImageUrl: item.ogImageUrl, + topViewUrl: item.topViewUrl, + rearViewUrl: item.rearViewUrl, + }; +} + +function serializeMount(mount: DeveloperApiMount) { + return { + value: mount.value, + shortName: mount.shortName, + }; +} + +function serializeSensorFormat(format: DeveloperApiSensorFormat | null) { + if (!format) return null; + return { + slug: format.slug, + name: format.name, + cropFactor: format.cropFactor, + }; +} + +function serializeColorway(colorway: GearColorway) { + return { + name: colorway.name, + slug: colorway.slug, + swatchColorA: colorway.swatchColorA, + swatchColorB: colorway.swatchColorB, + sortOrder: colorway.sortOrder, + frontImageUrl: colorway.frontImageUrl, + topViewUrl: colorway.topViewUrl, + rearViewUrl: colorway.rearViewUrl, + }; +} + +function serializeAfAreaMode(mode: AfAreaMode) { + return { + name: mode.name, + description: mode.description, + aliases: serializeStringArray(mode.aliases), + }; +} + +function serializeCameraCardSlot(slot: CameraCardSlot) { + return { + slotIndex: slot.slotIndex, + supportedFormFactors: slot.supportedFormFactors, + supportedBuses: slot.supportedBuses, + supportedSpeedClasses: slot.supportedSpeedClasses, + }; +} + +function serializeCameraSpecs( + specs: DeveloperApiCameraSpecs | null | undefined, +) { + if (!specs) return null; + return { + sensorFormat: serializeSensorFormat(specs.sensorFormat), + resolutionMp: specs.resolutionMp, + sensorStackingType: specs.sensorStackingType, + sensorTechType: specs.sensorTechType, + isBackSideIlluminated: specs.isBackSideIlluminated, + isoMin: specs.isoMin, + isoMax: specs.isoMax, + sensorReadoutSpeedMs: specs.sensorReadoutSpeedMs, + maxRawBitDepth: specs.maxRawBitDepth, + hasIbis: specs.hasIbis, + hasElectronicVibrationReduction: specs.hasElectronicVibrationReduction, + cipaStabilizationRatingStops: specs.cipaStabilizationRatingStops, + hasPixelShiftShooting: specs.hasPixelShiftShooting, + hasAntiAliasingFilter: specs.hasAntiAliasingFilter, + precaptureSupportLevel: specs.precaptureSupportLevel, + cameraType: specs.cameraType, + processorName: specs.processorName, + hasWeatherSealing: specs.hasWeatherSealing, + focusPoints: specs.focusPoints, + afSubjectCategories: specs.afSubjectCategories, + hasFocusPeaking: specs.hasFocusPeaking, + hasFocusBracketing: specs.hasFocusBracketing, + shutterSpeedMax: specs.shutterSpeedMax, + shutterSpeedMin: specs.shutterSpeedMin, + maxFpsRaw: specs.maxFpsRaw, + maxFpsJpg: specs.maxFpsJpg, + maxFpsByShutter: serializeMaxFpsByShutter(specs.maxFpsByShutter), + flashSyncSpeed: specs.flashSyncSpeed, + hasSilentShootingAvailable: specs.hasSilentShootingAvailable, + availableShutterTypes: specs.availableShutterTypes, + internalStorageGb: specs.internalStorageGb, + cipaBatteryShotsPerCharge: specs.cipaBatteryShotsPerCharge, + supportedBatteries: specs.supportedBatteries, + usbPowerDelivery: specs.usbPowerDelivery, + usbCharging: specs.usbCharging, + hasLogColorProfile: specs.hasLogColorProfile, + has10BitVideo: specs.has10BitVideo, + has12BitVideo: specs.has12BitVideo, + hasOpenGateVideo: specs.hasOpenGateVideo, + supportsExternalRecording: specs.supportsExternalRecording, + supportsRecordToDrive: specs.supportsRecordToDrive, + hasIntervalometer: specs.hasIntervalometer, + hasSelfTimer: specs.hasSelfTimer, + hasBuiltInFlash: specs.hasBuiltInFlash, + hasHotShoe: specs.hasHotShoe, + hasIlluminatedButtons: specs.hasIlluminatedButtons, + hasUsbFileTransfer: specs.hasUsbFileTransfer, + rearDisplayType: specs.rearDisplayType, + rearDisplayResolutionMillionDots: specs.rearDisplayResolutionMillionDots, + rearDisplaySizeInches: specs.rearDisplaySizeInches, + hasRearTouchscreen: specs.hasRearTouchscreen, + viewfinderType: specs.viewfinderType, + viewfinderMagnification: specs.viewfinderMagnification, + viewfinderResolutionMillionDots: specs.viewfinderResolutionMillionDots, + hasTopDisplay: specs.hasTopDisplay, + afAreaModes: (specs.afAreaModes ?? []).map(serializeAfAreaMode), + }; +} + +function serializeAnalogCameraSpecs( + specs: AnalogCameraSpecs | null | undefined, +) { + if (!specs) return null; + return { + cameraType: specs.cameraType, + captureMedium: specs.captureMedium, + filmTransportType: specs.filmTransportType, + hasAutoFilmAdvance: specs.hasAutoFilmAdvance, + hasOptionalMotorizedDrive: specs.hasOptionalMotorizedDrive, + viewfinderType: specs.viewfinderType, + shutterType: specs.shutterType, + shutterSpeedMax: specs.shutterSpeedMax, + shutterSpeedMin: specs.shutterSpeedMin, + flashSyncSpeed: specs.flashSyncSpeed, + hasBulbMode: specs.hasBulbMode, + hasMetering: specs.hasMetering, + meteringModes: specs.meteringModes, + exposureModes: specs.exposureModes, + meteringDisplayTypes: specs.meteringDisplayTypes, + hasExposureCompensation: specs.hasExposureCompensation, + isoSettingMethod: specs.isoSettingMethod, + isoMin: specs.isoMin, + isoMax: specs.isoMax, + hasAutoFocus: specs.hasAutoFocus, + focusAidTypes: specs.focusAidTypes, + requiresBatteryForShutter: specs.requiresBatteryForShutter, + requiresBatteryForMetering: specs.requiresBatteryForMetering, + supportedBatteries: specs.supportedBatteries, + hasContinuousDrive: specs.hasContinuousDrive, + maxContinuousFps: specs.maxContinuousFps, + hasHotShoe: specs.hasHotShoe, + hasSelfTimer: specs.hasSelfTimer, + hasIntervalometer: specs.hasIntervalometer, + }; +} + +function serializeLensSpecs(specs: DeveloperApiLensSpecs | null | undefined) { + if (!specs) return null; + return { + imageCircle: serializeSensorFormat(specs.imageCircle), + isPrime: specs.isPrime, + focalLengthMinMm: specs.focalLengthMinMm, + focalLengthMaxMm: specs.focalLengthMaxMm, + maxApertureWide: specs.maxApertureWide, + maxApertureTele: specs.maxApertureTele, + minApertureWide: specs.minApertureWide, + minApertureTele: specs.minApertureTele, + hasStabilization: specs.hasStabilization, + cipaStabilizationRatingStops: specs.cipaStabilizationRatingStops, + hasStabilizationSwitch: specs.hasStabilizationSwitch, + hasAutofocus: specs.hasAutofocus, + isMacro: specs.isMacro, + magnification: specs.magnification, + minimumFocusDistanceMm: specs.minimumFocusDistanceMm, + hasFocusRing: specs.hasFocusRing, + focusMotorType: specs.focusMotorType, + focusThrowDegrees: specs.focusThrowDegrees, + hasAfMfSwitch: specs.hasAfMfSwitch, + hasFocusLimiter: specs.hasFocusLimiter, + hasFocusRecallButton: specs.hasFocusRecallButton, + numberElements: specs.numberElements, + numberElementGroups: specs.numberElementGroups, + hasDiffractiveOptics: specs.hasDiffractiveOptics, + lensZoomType: specs.lensZoomType, + numberDiaphragmBlades: specs.numberDiaphragmBlades, + hasRoundedDiaphragmBlades: specs.hasRoundedDiaphragmBlades, + hasInternalZoom: specs.hasInternalZoom, + hasInternalFocus: specs.hasInternalFocus, + frontElementRotates: specs.frontElementRotates, + mountMaterial: specs.mountMaterial, + hasWeatherSealing: specs.hasWeatherSealing, + hasApertureRing: specs.hasApertureRing, + numberCustomControlRings: specs.numberCustomControlRings, + numberFunctionButtons: specs.numberFunctionButtons, + acceptsFilterTypes: specs.acceptsFilterTypes, + frontFilterThreadSizeMm: specs.frontFilterThreadSizeMm, + rearFilterThreadSizeMm: specs.rearFilterThreadSizeMm, + dropInFilterSizeMm: specs.dropInFilterSizeMm, + hasBuiltInTeleconverter: specs.hasBuiltInTeleconverter, + hasLensHood: specs.hasLensHood, + hasTripodCollar: specs.hasTripodCollar, + isTiltShift: specs.isTiltShift, + tiltDegrees: specs.tiltDegrees, + shiftMm: specs.shiftMm, + }; +} + +function serializeFixedLensSpecs( + specs: DeveloperApiFixedLensSpecs | null | undefined, +) { + if (!specs) return null; + return { + imageCircle: serializeSensorFormat(specs.imageCircle), + isPrime: specs.isPrime, + focalLengthMinMm: specs.focalLengthMinMm, + focalLengthMaxMm: specs.focalLengthMaxMm, + maxApertureWide: specs.maxApertureWide, + maxApertureTele: specs.maxApertureTele, + minApertureWide: specs.minApertureWide, + minApertureTele: specs.minApertureTele, + hasAutofocus: specs.hasAutofocus, + minimumFocusDistanceMm: specs.minimumFocusDistanceMm, + frontElementRotates: specs.frontElementRotates, + frontFilterThreadSizeMm: specs.frontFilterThreadSizeMm, + hasLensHood: specs.hasLensHood, + }; } export function serializeSearchResponse(result: SearchResponse) { @@ -70,7 +376,7 @@ export function serializeSearchResponse(result: SearchResponse) { msrpNowUsdCents: item.msrpNowUsdCents ?? null, msrpAtLaunchUsdCents: item.msrpAtLaunchUsdCents ?? null, mpbMaxPriceUsdCents: item.mpbMaxPriceUsdCents ?? null, - regionalAliases: serializeJson(item.regionalAliases ?? []), + regionalAliases: serializeAliases(item.regionalAliases), })), pagination: { page: result.page, @@ -126,29 +432,46 @@ export function serializeSuggestions(suggestions: Suggestion[]) { }; } -export function serializeGear(item: GearItem) { - const publicGear: Record = { ...item }; - // These relation fields are not part of the current beta contract. - delete publicGear.brandId; - delete publicGear.mountId; - delete publicGear.searchName; - // Full video-mode matrices are intentionally not part of the beta contract. - // Consumers can still use the published video capability specs selectively. - delete publicGear.videoModes; - if (item.rawSamples) { - publicGear.rawSamples = item.rawSamples.map((sample) => { - const publicSample: Record = { ...sample }; - delete publicSample.uploadedByUserId; - delete publicSample.isDeleted; - delete publicSample.deletedAt; - return publicSample; - }); - } +export function serializeGear(item: DeveloperApiGear) { + const media = serializeMedia(item); return { - data: serializeJsonWithOmittedKeys( - publicGear, - DEVELOPER_API_GEAR_METADATA_KEYS, - ), + data: serializeJson({ + slug: item.slug, + name: item.name, + modelNumber: item.modelNumber, + gearType: item.gearType, + announcedDate: serializeDate(item.announcedDate), + announceDatePrecision: item.announceDatePrecision, + releaseDate: serializeDate(item.releaseDate), + releaseDatePrecision: item.releaseDatePrecision, + msrpNowUsdCents: item.msrpNowUsdCents, + msrpAtLaunchUsdCents: item.msrpAtLaunchUsdCents, + mpbMaxPriceUsdCents: item.mpbMaxPriceUsdCents, + thumbnailUrl: media.thumbnailUrl, + ogImageUrl: media.ogImageUrl, + topViewUrl: media.topViewUrl, + rearViewUrl: media.rearViewUrl, + weightGrams: item.weightGrams, + widthMm: item.widthMm, + heightMm: item.heightMm, + depthMm: item.depthMm, + linkManufacturer: item.linkManufacturer, + linkInstructionManual: item.linkInstructionManual, + linkMpb: item.linkMpb, + linkBh: item.linkBh, + linkAmazon: item.linkAmazon, + brands: serializeBrand(item.brands), + mounts: item.mounts.map(serializeMount), + regionalAliases: serializeAliases(item.regionalAliases), + cameraSpecs: serializeCameraSpecs(item.cameraSpecs), + analogCameraSpecs: serializeAnalogCameraSpecs(item.analogCameraSpecs), + lensSpecs: serializeLensSpecs(item.lensSpecs), + fixedLensSpecs: serializeFixedLensSpecs(item.fixedLensSpecs), + cameraCardSlots: (item.cameraCardSlots ?? []).map( + serializeCameraCardSlot, + ), + colorways: (item.colorways ?? []).map(serializeColorway), + }), }; } diff --git a/src/server/developer-api/service.ts b/src/server/developer-api/service.ts index 2209a581..48b30c0b 100644 --- a/src/server/developer-api/service.ts +++ b/src/server/developer-api/service.ts @@ -1,13 +1,21 @@ import "server-only"; import { createHash, randomBytes } from "node:crypto"; +import { unstable_cache } from "next/cache"; import { requireRole } from "~/lib/auth/auth-helpers"; import { getSessionOrThrow } from "~/server/auth"; import { fetchGearBySlug } from "~/server/gear/service"; import { getSuggestions, searchGear } from "~/server/search/service"; -import type { GearRegion } from "~/types/gear"; +import type { + EnrichedCameraSpecs, + FixedLensSpecs, + GearItem, + GearRegion, + LensSpecs, +} from "~/types/gear"; import { DEVELOPER_API_KEY_DISPLAY_LENGTH, + DEVELOPER_API_CATALOG_CACHE_TAG, DEVELOPER_API_KEY_PREFIX, DEVELOPER_API_MAX_ACTIVE_KEYS, DEVELOPER_API_RATE_LIMIT, @@ -18,6 +26,9 @@ import { consumeRateLimitBucket, createApiKeyWithinActiveLimitData, findUsableApiKeyByHash, + fetchDeveloperGearMountsData, + fetchDeveloperCatalogData, + fetchDeveloperSensorFormatsData, getDeveloperAccessData, getUsageForKeyIdsSince, incrementUsageData, @@ -28,9 +39,12 @@ import { revokeApiKeyData, setDeveloperAccessData, touchApiKeyLastUsed, + type DeveloperApiMount, + type DeveloperApiSensorFormat, } from "./data"; import { DeveloperApiError } from "./errors"; import { parseKeyName } from "./schemas"; +import { serializeDeveloperCatalogData } from "./serializers"; export type DeveloperApiCredential = { apiKeyId: string; @@ -38,6 +52,83 @@ export type DeveloperApiCredential = { keyPrefix: string; }; +export type DeveloperApiCameraSpecs = Omit< + EnrichedCameraSpecs, + "gearId" | "sensorFormatId" +> & { + sensorFormat: DeveloperApiSensorFormat | null; +}; + +export type DeveloperApiLensSpecs = Omit< + LensSpecs, + "gearId" | "imageCircleSizeId" +> & { + imageCircle: DeveloperApiSensorFormat | null; +}; + +export type DeveloperApiFixedLensSpecs = Omit< + FixedLensSpecs, + "gearId" | "imageCircleSizeId" +> & { + imageCircle: DeveloperApiSensorFormat | null; +}; + +export type DeveloperApiGear = Omit< + GearItem, + "mountIds" | "mounts" | "cameraSpecs" | "lensSpecs" | "fixedLensSpecs" +> & { + mounts: DeveloperApiMount[]; + cameraSpecs: DeveloperApiCameraSpecs | null; + lensSpecs: DeveloperApiLensSpecs | null; + fixedLensSpecs: DeveloperApiFixedLensSpecs | null; +}; + +export type DeveloperApiCatalogSnapshot = { + version: string; + generatedAt: string; + itemCount: number; + data: ReturnType; +}; + +const getCachedDeveloperCatalogSnapshot = unstable_cache( + async (): Promise => { + const data = serializeDeveloperCatalogData( + await fetchDeveloperCatalogData(), + ); + const version = `sha256-${createHash("sha256") + .update(JSON.stringify(data)) + .digest("hex")}`; + + return { + version, + generatedAt: new Date().toISOString(), + itemCount: data.length, + data, + }; + }, + ["developer-api-catalog-v1"], + { revalidate: false, tags: [DEVELOPER_API_CATALOG_CACHE_TAG] }, +); + +export async function getDeveloperCatalogSnapshot() { + return getCachedDeveloperCatalogSnapshot(); +} + +export function createDeveloperCatalogEtag(version: string) { + return `"${version}"`; +} + +export function matchesDeveloperCatalogEtag( + ifNoneMatch: string | null, + etag: string, +) { + if (!ifNoneMatch) return false; + return ifNoneMatch.split(",").some((candidate) => { + const tag = candidate.trim(); + return tag === "*" || tag.replace(/^W\//i, "") === etag; + }); +} + function utcDay(date: Date) { return new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), @@ -356,6 +447,77 @@ export async function getDeveloperSuggestions( return getSuggestions(query, limit, region); } -export async function getDeveloperGear(slug: string) { +export async function getDeveloperGearForSpecs(slug: string) { return fetchGearBySlug(slug); } + +function composeDeveloperCameraSpecs( + specs: EnrichedCameraSpecs | null | undefined, + sensorFormat: DeveloperApiSensorFormat | null, +): DeveloperApiCameraSpecs | null { + if (!specs) return null; + const { gearId, sensorFormatId, ...values } = specs; + void gearId; + void sensorFormatId; + return { ...values, sensorFormat }; +} + +function composeDeveloperLensSpecs( + specs: LensSpecs | null | undefined, + imageCircle: DeveloperApiSensorFormat | null, +): DeveloperApiLensSpecs | null { + if (!specs) return null; + const { gearId, imageCircleSizeId, ...values } = specs; + void gearId; + void imageCircleSizeId; + return { ...values, imageCircle }; +} + +function composeDeveloperFixedLensSpecs( + specs: FixedLensSpecs | null | undefined, + imageCircle: DeveloperApiSensorFormat | null, +): DeveloperApiFixedLensSpecs | null { + if (!specs) return null; + const { gearId, imageCircleSizeId, ...values } = specs; + void gearId; + void imageCircleSizeId; + return { ...values, imageCircle }; +} + +export async function getDeveloperGear(slug: string) { + const gear = await getDeveloperGearForSpecs(slug); + const { mountIds, mounts: legacyMounts, ...gearWithoutMountTaxonomy } = gear; + void mountIds; + void legacyMounts; + const [mounts, sensorFormats] = await Promise.all([ + fetchDeveloperGearMountsData(gear.id), + fetchDeveloperSensorFormatsData([ + gear.cameraSpecs?.sensorFormatId, + gear.lensSpecs?.imageCircleSizeId, + gear.fixedLensSpecs?.imageCircleSizeId, + ]), + ]); + + return { + ...gearWithoutMountTaxonomy, + mounts, + cameraSpecs: composeDeveloperCameraSpecs( + gear.cameraSpecs, + (gear.cameraSpecs?.sensorFormatId + ? sensorFormats.get(gear.cameraSpecs.sensorFormatId) + : null) ?? null, + ), + lensSpecs: composeDeveloperLensSpecs( + gear.lensSpecs, + (gear.lensSpecs?.imageCircleSizeId + ? sensorFormats.get(gear.lensSpecs.imageCircleSizeId) + : null) ?? null, + ), + fixedLensSpecs: composeDeveloperFixedLensSpecs( + gear.fixedLensSpecs, + (gear.fixedLensSpecs?.imageCircleSizeId + ? sensorFormats.get(gear.fixedLensSpecs.imageCircleSizeId) + : null) ?? null, + ), + } satisfies DeveloperApiGear; +} diff --git a/src/server/developer-api/specs.ts b/src/server/developer-api/specs.ts index a9ab0d54..a3a5b5f9 100644 --- a/src/server/developer-api/specs.ts +++ b/src/server/developer-api/specs.ts @@ -6,7 +6,7 @@ import { getDeveloperApiSpecValue, } from "~/lib/specs/registry"; import { DeveloperApiError } from "./errors"; -import { getDeveloperGear } from "./service"; +import { getDeveloperGearForSpecs } from "./service"; export function getDeveloperApiSpecsCatalog() { return getDeveloperApiSpecCatalog(); @@ -53,7 +53,7 @@ export async function getDeveloperGearSelectedSpecs(params: { selectors: string[]; }) { const fields = resolveDeveloperApiSpecSelectors(params.selectors); - const gear = await getDeveloperGear(params.slug); + const gear = await getDeveloperGearForSpecs(params.slug); return fields .map((definition) => getDeveloperApiSpecValue(gear, definition)) diff --git a/tests/unit/admin-gear-thumbnail-service.test.ts b/tests/unit/admin-gear-thumbnail-service.test.ts index 9b5f8385..0190b5f3 100644 --- a/tests/unit/admin-gear-thumbnail-service.test.ts +++ b/tests/unit/admin-gear-thumbnail-service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach,describe,expect,it,vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const authMocks = vi.hoisted(() => ({ getSessionOrThrow: vi.fn(), @@ -37,6 +37,10 @@ const dbMocks = vi.hoisted(() => ({ transaction: vi.fn(), })); +const cacheMocks = vi.hoisted(() => ({ + invalidateCatalog: vi.fn(), +})); + vi.mock("server-only", () => ({})); vi.mock("~/server/auth", () => authMocks); vi.mock("~/server/db", () => ({ @@ -51,6 +55,9 @@ vi.mock("~/server/db/schema", () => ({ })); vi.mock("~/server/admin/gear/data", () => adminGearDataMocks); vi.mock("~/server/gear/data", () => gearDataMocks); +vi.mock("~/server/developer-api/cache", () => ({ + invalidateDeveloperApiCatalogCache: cacheMocks.invalidateCatalog, +})); import { clearGearThumbnailService, @@ -95,11 +102,14 @@ describe("thumbnail gear admin service", () => { thumbnailUrl: "https://cdn.example.com/front.jpg", ogImageUrl: "https://cdn.example.com/front-og.jpg", }); - expect(gearDataMocks.clearImageRequestsForGear).toHaveBeenCalledWith("gear-1"); + expect(gearDataMocks.clearImageRequestsForGear).toHaveBeenCalledWith( + "gear-1", + ); expect(dbState.insertedValues[0]).toMatchObject({ action: "GEAR_IMAGE_UPLOAD", gearId: "gear-1", }); + expect(cacheMocks.invalidateCatalog).toHaveBeenCalledTimes(1); }); it("clears any stored OG asset when replacing an existing thumbnail", async () => { @@ -129,6 +139,26 @@ describe("thumbnail gear admin service", () => { }); }); + it("does not invalidate the catalog when only the OG asset changes", async () => { + gearDataMocks.fetchGearMetadataById.mockResolvedValue({ + thumbnailUrl: "https://cdn.example.com/front.jpg", + }); + adminGearDataMocks.updateGearThumbnailData.mockResolvedValue({ + id: "gear-1", + slug: "nikon-z6iii", + thumbnailUrl: "https://cdn.example.com/front.jpg", + ogImageUrl: "https://cdn.example.com/front-og.jpg", + }); + + await setGearThumbnailService({ + gearId: "gear-1", + thumbnailUrl: "https://cdn.example.com/front.jpg", + ogImageUrl: "https://cdn.example.com/front-og.jpg", + }); + + expect(cacheMocks.invalidateCatalog).not.toHaveBeenCalled(); + }); + it("clearing a thumbnail also clears the stored OG asset", async () => { gearDataMocks.fetchGearMetadataById.mockResolvedValue({ thumbnailUrl: "https://cdn.example.com/front.jpg", @@ -173,5 +203,6 @@ describe("thumbnail gear admin service", () => { gearId: "gear-1", ogImageUrl: "https://cdn.example.com/front-og.jpg", }); + expect(cacheMocks.invalidateCatalog).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/developer-api-cache.test.ts b/tests/unit/developer-api-cache.test.ts new file mode 100644 index 00000000..cac3d0d7 --- /dev/null +++ b/tests/unit/developer-api-cache.test.ts @@ -0,0 +1,16 @@ +import { expect, it, vi } from "vitest"; + +const revalidateTag = vi.hoisted(() => vi.fn()); + +vi.mock("server-only", () => ({})); +vi.mock("next/cache", () => ({ revalidateTag })); + +import { invalidateDeveloperApiCatalogCache } from "~/server/developer-api/cache"; + +it("expires the developer catalog snapshot immediately", () => { + invalidateDeveloperApiCatalogCache(); + + expect(revalidateTag).toHaveBeenCalledWith("developer-api-catalog", { + expire: 0, + }); +}); diff --git a/tests/unit/developer-api-catalog-route.test.ts b/tests/unit/developer-api-catalog-route.test.ts new file mode 100644 index 00000000..220da16d --- /dev/null +++ b/tests/unit/developer-api-catalog-route.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSnapshot: vi.fn(), + matchesEtag: vi.fn(), + runRequest: vi.fn( + async ( + _request: Request, + _endpoint: string, + handler: () => Promise, + ) => { + const response = (await handler()) as { response?: Response }; + return response.response ?? response; + }, + ), +})); + +vi.mock("~/server/developer-api/http", () => ({ + runDeveloperApiRequest: mocks.runRequest, +})); +vi.mock("~/server/developer-api/service", () => ({ + getDeveloperCatalogSnapshot: mocks.getSnapshot, + createDeveloperCatalogEtag: (version: string) => `"${version}"`, + matchesDeveloperCatalogEtag: mocks.matchesEtag, +})); + +import { GET } from "~/app/api/v1/catalog/route"; + +describe("developer API catalog route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSnapshot.mockResolvedValue({ + version: "sha256-catalog", + generatedAt: "2026-07-15T15:00:00.000Z", + itemCount: 1, + data: [{ slug: "nikon-z6iii" }], + }); + mocks.matchesEtag.mockReturnValue(false); + }); + + it("returns the cached snapshot with revalidation headers", async () => { + await expect( + GET(new Request("https://sharply.test/api/v1/catalog")), + ).resolves.toEqual({ + version: "sha256-catalog", + generatedAt: "2026-07-15T15:00:00.000Z", + itemCount: 1, + data: [{ slug: "nikon-z6iii" }], + headers: { + "Cache-Control": "private, max-age=0, must-revalidate", + Vary: "Authorization, If-None-Match", + ETag: '"sha256-catalog"', + }, + }); + expect(mocks.runRequest).toHaveBeenCalledWith( + expect.any(Request), + "catalog", + expect.any(Function), + ); + }); + + it("returns native 304 when the request snapshot matches", async () => { + mocks.matchesEtag.mockReturnValue(true); + + const response = await GET( + new Request("https://sharply.test/api/v1/catalog", { + headers: { "If-None-Match": 'W/"sha256-catalog"' }, + }), + ); + + expect(response.status).toBe(304); + expect(response.headers.get("ETag")).toBe('"sha256-catalog"'); + expect(response.headers.get("Cache-Control")).toBe( + "private, max-age=0, must-revalidate", + ); + }); +}); diff --git a/tests/unit/developer-api-http.test.ts b/tests/unit/developer-api-http.test.ts index 399805b0..3aa4d51e 100644 --- a/tests/unit/developer-api-http.test.ts +++ b/tests/unit/developer-api-http.test.ts @@ -64,6 +64,28 @@ describe("developer API HTTP wrapper", () => { logError.mockRestore(); }); + it("passes through native 304 responses with request and rate-limit headers", async () => { + const response = await runDeveloperApiRequest( + new Request("https://sharply.test/api/v1/catalog"), + "catalog", + async () => ({ + response: new Response(null, { + status: 304, + headers: { ETag: '"sha256-catalog"' }, + }), + }), + ); + + expect(response.status).toBe(304); + expect(response.headers.get("ETag")).toBe('"sha256-catalog"'); + expect(response.headers.get("X-Request-Id")).toBeTruthy(); + expect(response.headers.get("X-RateLimit-Remaining")).toBe("59"); + expect(serviceMocks.recordDeveloperApiUsage).toHaveBeenCalledWith({ + apiKeyId: "key-1", + endpoint: "catalog", + }); + }); + it("returns a consistent 429 without recording usage", async () => { serviceMocks.consumeDeveloperRateLimit.mockResolvedValue({ allowed: false, diff --git a/tests/unit/developer-api-schemas-serializers.test.ts b/tests/unit/developer-api-schemas-serializers.test.ts index 431b5628..9368bc83 100644 --- a/tests/unit/developer-api-schemas-serializers.test.ts +++ b/tests/unit/developer-api-schemas-serializers.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { DeveloperApiError } from "~/server/developer-api/errors"; import { + serializeDeveloperCatalogData, serializeDeveloperApiSpecs, serializeGear, + serializeSearchResponse, serializeSuggestions, } from "~/server/developer-api/serializers"; import { @@ -42,6 +44,60 @@ describe("developer API request schemas", () => { }); describe("developer API serializers", () => { + it("serializes only the lightweight catalog allowlist in slug order", () => { + const catalog = serializeDeveloperCatalogData([ + { + name: "Alpha", + slug: "alpha", + brandName: "Acme", + gearType: "CAMERA", + thumbnailUrl: "https://example.test/alpha.jpg", + releaseDate: new Date("2024-06-24T00:00:00.000Z"), + releaseDatePrecision: "DAY", + announcedDate: null, + announceDatePrecision: null, + id: "internal-id", + createdAt: new Date(), + }, + { + name: "Zulu", + slug: "zulu", + brandName: "Acme", + gearType: "LENS", + thumbnailUrl: null, + releaseDate: null, + releaseDatePrecision: null, + announcedDate: new Date("2024-06-17T00:00:00.000Z"), + announceDatePrecision: "DAY", + }, + ] as never); + + expect(catalog).toEqual([ + { + name: "Alpha", + slug: "alpha", + brandName: "Acme", + gearType: "CAMERA", + thumbnailUrl: "https://example.test/alpha.jpg", + releaseDate: "2024-06-24T00:00:00.000Z", + releaseDatePrecision: "DAY", + announcedDate: null, + announceDatePrecision: null, + }, + { + name: "Zulu", + slug: "zulu", + brandName: "Acme", + gearType: "LENS", + thumbnailUrl: null, + releaseDate: null, + releaseDatePrecision: null, + announcedDate: "2024-06-17T00:00:00.000Z", + announceDatePrecision: "DAY", + }, + ]); + }); + it("removes website-only smart actions from suggestions", () => { const response = serializeSuggestions([ { @@ -82,34 +138,117 @@ describe("developer API serializers", () => { ]); }); - it("serializes full gear data while excluding database implementation fields", () => { + it("serializes an exact allowlisted full-gear payload", () => { const response = serializeGear({ id: "internal-id", + internalOnly: "future database column", brandId: "brand-id", mountId: "legacy-mount", searchName: "nikon z6", slug: "nikon-z6", name: "Nikon Z6", + modelNumber: "Z6", gearType: "CAMERA", publicationState: "PUBLISHED", + notes: ["Internal editor note"], + announcedDate: new Date("2023-10-01T00:00:00.000Z"), + announceDatePrecision: "DAY", + releaseDate: new Date("2024-01-01T00:00:00.000Z"), + releaseDatePrecision: "DAY", + thumbnailUrl: "https://example.test/front.jpg", + topViewUrl: "https://example.test/top.jpg", + rearViewUrl: "https://example.test/rear.jpg", + widthMm: "134", + mounts: [ + { value: "Nikon Z", shortName: "Z" }, + { value: "Leica L", shortName: "L" }, + ], createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), brands: { id: "brand-id", + internalOnly: "future brand field", name: "Nikon", + slug: "nikon", + sortOrder: 1, createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), }, + regionalAliases: [ + { + gearId: "internal-id", + region: "JP", + name: "Nikon Z6 JP", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + ], cameraSpecs: { gearId: "internal-id", + internalOnly: "future camera spec field", + sensorFormatId: "sensor-format-id", + sensorFormat: { + id: "sensor-format-id", + slug: "full-frame", + name: "Full Frame", + cropFactor: "1.00", + }, + resolutionMp: "24.5", + maxFpsByShutter: { mechanical: { raw: 14, jpg: 14 } }, + extra: { internalOnly: true }, + afAreaModes: [ + { + id: "af-mode-id", + brandId: "brand-id", + searchName: "single point", + name: "Single-point AF", + description: "Public description", + aliases: ["single point", 42], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + ], + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + analogCameraSpecs: { + gearId: "internal-id", + cameraType: "slr", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + lensSpecs: { + gearId: "internal-id", + imageCircleSizeId: "sensor-format-id", + imageCircle: { + id: "sensor-format-id", + slug: "full-frame", + name: "Full Frame", + cropFactor: "1.00", + }, + focalLengthMinMm: 24, + extra: { internalOnly: true }, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + fixedLensSpecs: { + gearId: "internal-id", + imageCircleSizeId: "sensor-format-id", + imageCircle: null, + focalLengthMinMm: 35, createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), }, cameraCardSlots: [ { id: "card-slot-id", + internalOnly: "future card-slot field", gearId: "internal-id", slotIndex: 1, + supportedFormFactors: ["sd"], + supportedBuses: ["uhs_ii"], + supportedSpeedClasses: ["v90"], + notes: "Internal slot note", createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-02T00:00:00.000Z"), }, @@ -125,31 +264,190 @@ describe("developer API serializers", () => { rawSamples: [ { id: "sample-1", + internalOnly: "future sample field", fileUrl: "https://example.test/sample.nef", + originalFilename: "sample.nef", + contentType: "image/x-nikon-nef", + sizeBytes: 123, uploadedByUserId: "user-1", isDeleted: false, deletedAt: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + ], + colorways: [ + { + id: "colorway-id", + internalOnly: "future colorway field", + gearId: "internal-id", + name: "Black", + slug: "black", + swatchColorA: "#000000", + swatchColorB: "#111111", + sortOrder: 1, + frontImageUrl: "https://example.test/black-front.jpg", + topViewUrl: null, + rearViewUrl: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), }, ], } as never); - expect(response.data).toMatchObject({ + expect(response.data).toEqual({ slug: "nikon-z6", - brands: { name: "Nikon" }, - cameraSpecs: { gearId: "internal-id" }, - cameraCardSlots: [{ gearId: "internal-id", slotIndex: 1 }], + name: "Nikon Z6", + modelNumber: "Z6", + gearType: "CAMERA", + announcedDate: "2023-10-01T00:00:00.000Z", + announceDatePrecision: "DAY", + releaseDate: "2024-01-01T00:00:00.000Z", + releaseDatePrecision: "DAY", + thumbnailUrl: "https://example.test/front.jpg", + topViewUrl: "https://example.test/top.jpg", + rearViewUrl: "https://example.test/rear.jpg", + widthMm: "134", + brands: { name: "Nikon", slug: "nikon" }, + mounts: [ + { value: "Nikon Z", shortName: "Z" }, + { value: "Leica L", shortName: "L" }, + ], + regionalAliases: [{ region: "JP", name: "Nikon Z6 JP" }], + cameraSpecs: { + sensorFormat: { + slug: "full-frame", + name: "Full Frame", + cropFactor: "1.00", + }, + resolutionMp: "24.5", + maxFpsByShutter: { mechanical: { raw: 14, jpg: 14 } }, + afAreaModes: [ + { + name: "Single-point AF", + description: "Public description", + aliases: ["single point"], + }, + ], + }, + analogCameraSpecs: { cameraType: "slr" }, + lensSpecs: { + imageCircle: { + slug: "full-frame", + name: "Full Frame", + cropFactor: "1.00", + }, + focalLengthMinMm: 24, + }, + fixedLensSpecs: { imageCircle: null, focalLengthMinMm: 35 }, + cameraCardSlots: [ + { + slotIndex: 1, + supportedFormFactors: ["sd"], + supportedBuses: ["uhs_ii"], + supportedSpeedClasses: ["v90"], + }, + ], + colorways: [ + { + name: "Black", + slug: "black", + swatchColorA: "#000000", + swatchColorB: "#111111", + sortOrder: 1, + frontImageUrl: "https://example.test/black-front.jpg", + topViewUrl: null, + rearViewUrl: null, + }, + ], }); expect(response.data).not.toHaveProperty("id"); + expect(response.data).not.toHaveProperty("internalOnly"); expect(response.data).not.toHaveProperty("createdAt"); expect(response.data).not.toHaveProperty("updatedAt"); expect(response.data).not.toHaveProperty("searchName"); + expect(response.data).not.toHaveProperty("publicationState"); + expect(response.data).not.toHaveProperty("notes"); + expect(response.data).not.toHaveProperty("genres"); + expect(response.data).not.toHaveProperty("mountIds"); + expect(response.data).not.toHaveProperty("mounts.0.id"); expect(response.data).not.toHaveProperty("videoModes"); expect(response.data).not.toHaveProperty("brands.id"); + expect(response.data).not.toHaveProperty("brands.internalOnly"); expect(response.data).not.toHaveProperty("brands.createdAt"); + expect(response.data).not.toHaveProperty("regionalAliases.0.gearId"); expect(response.data).not.toHaveProperty("cameraSpecs.createdAt"); + expect(response.data).not.toHaveProperty("cameraSpecs.sensorFormatId"); + expect(response.data).not.toHaveProperty("cameraSpecs.sensorFormat.id"); + expect(response.data).not.toHaveProperty("cameraSpecs.extra"); + expect(response.data).not.toHaveProperty("cameraSpecs.internalOnly"); + expect(response.data).not.toHaveProperty("cameraSpecs.afAreaModes.0.id"); + expect(response.data).not.toHaveProperty("lensSpecs.imageCircle.id"); expect(response.data).not.toHaveProperty("cameraCardSlots.0.id"); expect(response.data).not.toHaveProperty("cameraCardSlots.0.updatedAt"); - expect(response.data).not.toHaveProperty("rawSamples.0.uploadedByUserId"); + expect(response.data).not.toHaveProperty("cameraCardSlots.0.notes"); + expect(response.data).not.toHaveProperty("cameraCardSlots.0.internalOnly"); + expect(response.data).not.toHaveProperty("rawSamples"); + expect(response.data).not.toHaveProperty("colorways.0.gearId"); + expect(response.data).not.toHaveProperty("colorways.0.internalOnly"); + }); + + it("uses stable empty or null values for absent full-gear relations", () => { + const response = serializeGear({ + slug: "nikon-z6", + name: "Nikon Z6", + gearType: "CAMERA", + mounts: [], + } as never); + + expect(response.data).toMatchObject({ + slug: "nikon-z6", + name: "Nikon Z6", + gearType: "CAMERA", + brands: null, + regionalAliases: [], + cameraSpecs: null, + analogCameraSpecs: null, + lensSpecs: null, + fixedLensSpecs: null, + cameraCardSlots: [], + colorways: [], + }); + }); + + it("allowlists regional aliases in search responses", () => { + const response = serializeSearchResponse({ + results: [ + { + id: "internal-id", + slug: "nikon-z6", + name: "Nikon Z6", + brandName: "Nikon", + mountValue: null, + gearType: "CAMERA", + thumbnailUrl: null, + regionalAliases: [ + { + gearId: "internal-id", + region: "JP", + name: "Nikon Z6 JP", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + ], + }, + ], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + }); + + expect(response.data[0]?.regionalAliases).toEqual([ + { region: "JP", name: "Nikon Z6 JP" }, + ]); + expect(response.data[0]).not.toHaveProperty("regionalAliases.0.gearId"); + expect(response.data[0]).not.toHaveProperty("regionalAliases.0.createdAt"); }); it("serializes selected specs with structured raw values", () => { diff --git a/tests/unit/developer-api-service.test.ts b/tests/unit/developer-api-service.test.ts new file mode 100644 index 00000000..d466dfd7 --- /dev/null +++ b/tests/unit/developer-api-service.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + fetchCatalog: vi.fn(), + fetchGearBySlug: vi.fn(), + fetchMounts: vi.fn(), + fetchSensorFormats: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +vi.mock("~/server/db", () => ({ + db: {}, +})); + +vi.mock("~/server/gear/service", () => ({ + fetchGearBySlug: mocks.fetchGearBySlug, +})); + +vi.mock("~/server/auth", () => ({ + getSessionOrThrow: vi.fn(), +})); + +vi.mock("~/server/search/service", () => ({ + getSuggestions: vi.fn(), + searchGear: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + unstable_cache: (resolver: () => Promise) => resolver, +})); + +vi.mock("~/server/developer-api/data", () => ({ + consumeRateLimitBucket: vi.fn(), + createApiKeyWithinActiveLimitData: vi.fn(), + findUsableApiKeyByHash: vi.fn(), + fetchDeveloperCatalogData: mocks.fetchCatalog, + fetchDeveloperGearMountsData: mocks.fetchMounts, + fetchDeveloperSensorFormatsData: mocks.fetchSensorFormats, + getDeveloperAccessData: vi.fn(), + getUsageForKeyIdsSince: vi.fn(), + incrementUsageData: vi.fn(), + listApiKeysForUser: vi.fn(), + listAllApiKeysData: vi.fn(), + listDeveloperUsersData: vi.fn(), + revokeAllApiKeysForUser: vi.fn(), + revokeApiKeyData: vi.fn(), + setDeveloperAccessData: vi.fn(), + touchApiKeyLastUsed: vi.fn(), +})); + +import { + createDeveloperCatalogEtag, + getDeveloperCatalogSnapshot, + getDeveloperGear, + matchesDeveloperCatalogEtag, +} from "~/server/developer-api/service"; + +describe("getDeveloperGear", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.fetchGearBySlug.mockResolvedValue({ + id: "gear-id", + slug: "nikon-z6", + name: "Nikon Z6", + gearType: "CAMERA", + mountIds: ["mount-z", "mount-l"], + cameraSpecs: { gearId: "gear-id", sensorFormatId: "format-camera" }, + lensSpecs: { gearId: "gear-id", imageCircleSizeId: "format-lens" }, + fixedLensSpecs: { + gearId: "gear-id", + imageCircleSizeId: "missing-format", + }, + }); + mocks.fetchMounts.mockResolvedValue([ + { value: "Nikon Z", shortName: "Z" }, + { value: "Leica L", shortName: "L" }, + ]); + mocks.fetchSensorFormats.mockResolvedValue( + new Map([ + [ + "format-camera", + { slug: "full-frame", name: "Full Frame", cropFactor: "1.00" }, + ], + ["format-lens", { slug: "aps-c", name: "APS-C", cropFactor: "1.50" }], + ]), + ); + }); + + it("composes named taxonomy DTOs without exposing foreign keys", async () => { + const gear = await getDeveloperGear("nikon-z6"); + + expect(gear).toMatchObject({ + slug: "nikon-z6", + mounts: [ + { value: "Nikon Z", shortName: "Z" }, + { value: "Leica L", shortName: "L" }, + ], + cameraSpecs: { + sensorFormat: { + slug: "full-frame", + name: "Full Frame", + cropFactor: "1.00", + }, + }, + lensSpecs: { + imageCircle: { + slug: "aps-c", + name: "APS-C", + cropFactor: "1.50", + }, + }, + fixedLensSpecs: { imageCircle: null }, + }); + + expect(gear).not.toHaveProperty("mountIds"); + expect(gear.cameraSpecs).not.toHaveProperty("sensorFormatId"); + expect(gear.lensSpecs).not.toHaveProperty("imageCircleSizeId"); + expect(gear.fixedLensSpecs).not.toHaveProperty("imageCircleSizeId"); + expect(mocks.fetchMounts).toHaveBeenCalledWith("gear-id"); + expect(mocks.fetchSensorFormats).toHaveBeenCalledWith([ + "format-camera", + "format-lens", + "missing-format", + ]); + }); + + it("builds a stable catalog version from its serialized data", async () => { + mocks.fetchCatalog.mockResolvedValue([ + { + name: "Nikon Z6III", + slug: "nikon-z6iii", + brandName: "Nikon", + gearType: "CAMERA", + thumbnailUrl: null, + releaseDate: new Date("2024-06-24T00:00:00.000Z"), + releaseDatePrecision: "DAY", + announcedDate: null, + announceDatePrecision: null, + }, + ]); + + const first = await getDeveloperCatalogSnapshot(); + const second = await getDeveloperCatalogSnapshot(); + const etag = createDeveloperCatalogEtag(first.version); + + expect(first).toMatchObject({ + version: expect.stringMatching(/^sha256-[a-f0-9]{64}$/), + itemCount: 1, + data: [ + expect.objectContaining({ + slug: "nikon-z6iii", + releaseDate: "2024-06-24T00:00:00.000Z", + }), + ], + }); + expect(second.version).toBe(first.version); + expect(matchesDeveloperCatalogEtag(etag, etag)).toBe(true); + expect(matchesDeveloperCatalogEtag(`W/${etag}, "other"`, etag)).toBe(true); + expect(matchesDeveloperCatalogEtag("*", etag)).toBe(true); + expect(matchesDeveloperCatalogEtag('"other"', etag)).toBe(false); + }); +}); diff --git a/tests/unit/developer-docs-page.test.ts b/tests/unit/developer-docs-page.test.ts index 4f5b8ea0..83d087d5 100644 --- a/tests/unit/developer-docs-page.test.ts +++ b/tests/unit/developer-docs-page.test.ts @@ -63,6 +63,7 @@ describe("DeveloperDocsPage", () => { ); expect(markup).toContain("/api/v1/search"); + expect(markup).toContain("/api/v1/catalog"); expect(markup).toContain("/api/v1/specs"); expect(markup).toContain("/api/v1/gear/:slug/specs"); expect(markup).toContain("camera.sensor"); @@ -70,16 +71,22 @@ describe("DeveloperDocsPage", () => { expect(markup).toContain('aria-haspopup="dialog"'); expect(markup).not.toContain("", + "/api/v1/catalog", "/api/v1/gear/:slug", "/api/v1/gear/:slug/specs", "/api/v1/specs", diff --git a/tests/unit/static-route-safety.test.ts b/tests/unit/static-route-safety.test.ts index a06486de..e21368e5 100644 --- a/tests/unit/static-route-safety.test.ts +++ b/tests/unit/static-route-safety.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { describe,expect,it } from "vitest"; +import { describe, expect, it } from "vitest"; function readSource(relativePath: string) { return readFileSync(path.join(process.cwd(), relativePath), "utf8"); @@ -48,6 +48,14 @@ describe("static route safety", () => { ); }); + it("refreshes the header session without its cookie cache for developer access", () => { + const headerClient = readSource("src/components/layout/header-client.tsx"); + + expect(headerClient).toContain( + "refetchSession({ query: { disableCookieCache: true } })", + ); + }); + it("keeps the footer language switcher isolated behind Suspense", () => { const footer = readSource("src/components/layout/footer.tsx"); @@ -67,7 +75,9 @@ describe("static route safety", () => { }); it("keeps gear ISR routes anchored to params locale and off request APIs", () => { - const gearPage = readSource("src/app/[locale]/(pages)/gear/[slug]/page.tsx"); + const gearPage = readSource( + "src/app/[locale]/(pages)/gear/[slug]/page.tsx", + ); expect(gearPage).toMatch(/setRequestLocale\(locale\);/); expect(gearPage).not.toMatch(/searchParams:/); @@ -80,7 +90,9 @@ describe("static route safety", () => { it("rejects invalid locales during root layout metadata resolution", () => { const localeLayout = readSource("src/app/[locale]/layout.tsx"); - expect(localeLayout).toMatch(/if \(!isLocale\(requestedLocale\)\) {\s*notFound\(\);/); + expect(localeLayout).toMatch( + /if \(!isLocale\(requestedLocale\)\) {\s*notFound\(\);/, + ); }); it("mounts BotID at the locale root instead of shared page chrome", () => {