diff --git a/composables/useRecordCache.ts b/composables/useRecordCache.ts index bb1f7f9d..846baec5 100644 --- a/composables/useRecordCache.ts +++ b/composables/useRecordCache.ts @@ -1,4 +1,4 @@ -import { resolveViewTypeForTable } from "@/composables/useViewType"; +import { resolveRecordFetchQuery } from "@/composables/useViewType"; import { encodeDatasetNameForUrl } from "@/utils/identifierUtils"; import type { DataEntry } from "@/types"; @@ -73,13 +73,11 @@ export const useRecordCache = () => { return pending.get(cacheKey)!; } - // resolveViewTypeForTable returns undefined on purpose (cross-table read, non-view - // route, missing :tablename) — callers omit view_type and the server uses its default. - const viewType = resolveViewTypeForTable(route, table); + const query = resolveRecordFetchQuery(route, table); const url = `/api/${encodeDatasetNameForUrl(table)}/${encodeURIComponent(recordId)}`; const request = ( - viewType - ? $fetch(url, { query: { view_type: viewType } }) + Object.keys(query).length > 0 + ? $fetch(url, { query }) : $fetch(url) ) .then((record) => { @@ -139,13 +137,12 @@ export const useRecordCache = () => { } try { - // Same as fetchRecord: undefined viewType → omit param (see useViewType.ts). - const viewType = resolveViewTypeForTable(route, table); + const query = resolveRecordFetchQuery(route, table); const batchPromises = batches.map((batch) => $fetch(`/api/${encodeDatasetNameForUrl(table)}/records`, { method: "POST", body: { ids: batch }, - ...(viewType ? { query: { view_type: viewType } } : {}), + ...(Object.keys(query).length > 0 ? { query } : {}), }), ); const batchResults = await Promise.all(batchPromises); diff --git a/composables/useViewType.ts b/composables/useViewType.ts index bb5170b7..9b63b183 100644 --- a/composables/useViewType.ts +++ b/composables/useViewType.ts @@ -1,4 +1,4 @@ -import type { ViewType } from "@/types"; +import type { RecordFetchQuery, ViewType } from "@/types"; import { decodeDatasetNameFromUrl } from "@/utils/identifierUtils"; /** @@ -49,3 +49,41 @@ export function resolveViewTypeForTable( const firstSegment = route.path.split("/").filter(Boolean)[0]; return firstSegment ? VIEW_TYPE_BY_SEGMENT[firstSegment] : undefined; } + +/** + * Builds query params for record requests. + * + * Requests for the view's primary dataset send `view_type`. Requests for its + * secondary dataset also send `primary_dataset`, which identifies the view + * configuration that lists the requested secondary dataset. + * + * @param {{ path: string; params: Record }} route - Current page route. + * @param {string} requestedDataset - Dataset being fetched. + * @returns {RecordFetchQuery} Query object (possibly empty). + */ +export const resolveRecordFetchQuery = ( + route: { path: string; params: Record }, + requestedDataset: string, +): RecordFetchQuery => { + const primaryDataset = + typeof route.params.tablename === "string" + ? decodeDatasetNameFromUrl(route.params.tablename) + : undefined; + const firstSegment = route.path.split("/").filter(Boolean)[0]; + const routeViewType = firstSegment + ? VIEW_TYPE_BY_SEGMENT[firstSegment] + : undefined; + + if (!primaryDataset || !routeViewType) { + return {}; + } + + if (decodeDatasetNameFromUrl(requestedDataset) === primaryDataset) { + return { view_type: routeViewType }; + } + + return { + view_type: routeViewType, + primary_dataset: primaryDataset, + }; +}; diff --git a/server/api/[table]/[recordId].get.ts b/server/api/[table]/[recordId].get.ts index b565b466..25f07b12 100644 --- a/server/api/[table]/[recordId].get.ts +++ b/server/api/[table]/[recordId].get.ts @@ -1,4 +1,7 @@ -import { fetchRecord, fetchTableConfig } from "@/server/database/dbOperations"; +import { + fetchRecord, + fetchViewConfigForDatasetRead, +} from "@/server/database/dbOperations"; import { getRecordIdParam, getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -8,10 +11,18 @@ import type { ViewType } from "@/types"; export default defineEventHandler(async (event: H3Event) => { const table = getTableParam(event); const recordId = getRecordIdParam(event); - const viewType = getQuery(event).view_type as ViewType | undefined; + const query = getQuery(event); + const viewType = query.view_type as ViewType | undefined; + const primaryDataset = + typeof query.primary_dataset === "string" + ? query.primary_dataset + : undefined; try { - const tableConfig = await fetchTableConfig(table, viewType); + const tableConfig = await fetchViewConfigForDatasetRead(table, { + viewType, + primaryDataset, + }); // Check visibility permissions const permission = tableConfig.ROUTE_LEVEL_PERMISSION ?? "member"; diff --git a/server/api/[table]/records.post.ts b/server/api/[table]/records.post.ts index 72a8770b..c4abac3b 100644 --- a/server/api/[table]/records.post.ts +++ b/server/api/[table]/records.post.ts @@ -1,4 +1,7 @@ -import { fetchRecords, fetchTableConfig } from "@/server/database/dbOperations"; +import { + fetchRecords, + fetchViewConfigForDatasetRead, +} from "@/server/database/dbOperations"; import { getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -9,7 +12,12 @@ const MAX_IDS = 500; /** NOTE: The endpoint does not guarantee that records are returned in the same order as requested IDs. Consumers must not rely on response ordering. */ export default defineEventHandler(async (event: H3Event) => { const table = getTableParam(event); - const viewType = getQuery(event).view_type as ViewType | undefined; + const query = getQuery(event); + const viewType = query.view_type as ViewType | undefined; + const primaryDataset = + typeof query.primary_dataset === "string" + ? query.primary_dataset + : undefined; const body = await readBody(event); @@ -37,7 +45,10 @@ export default defineEventHandler(async (event: H3Event) => { } try { - const tableConfig = await fetchTableConfig(table, viewType); + const tableConfig = await fetchViewConfigForDatasetRead(table, { + viewType, + primaryDataset, + }); const permission = tableConfig.ROUTE_LEVEL_PERMISSION ?? "member"; await validatePermissions(event, permission); diff --git a/server/database/dbOperations.ts b/server/database/dbOperations.ts index d349ae26..7a7f2fd1 100644 --- a/server/database/dbOperations.ts +++ b/server/database/dbOperations.ts @@ -611,6 +611,63 @@ export const fetchTableConfig = async ( } }; +/** + * Returns the view config for a dataset read. + * + * View configs are keyed by primary dataset and view type. A secondary dataset + * request therefore includes both, and must match the configured secondary + * dataset before using that config. + * + * @param {string} requestedDataset - Warehouse dataset being read. + * @param {{ viewType?: ViewType; primaryDataset?: string | null }} [options] - View identity. + * @returns {Promise} Config for the requested dataset's view. + */ +export const fetchViewConfigForDatasetRead = async ( + requestedDataset: string, + options: { + viewType?: ViewType; + primaryDataset?: string | null; + } = {}, +): Promise => { + const normalizedRequestedDataset = normalizeTableName(requestedDataset); + const primaryDataset = options.primaryDataset?.trim() + ? normalizeTableName(options.primaryDataset) + : null; + + if (!primaryDataset) { + return fetchTableConfig(normalizedRequestedDataset, options.viewType); + } + + if (!options.viewType) { + throw Object.assign( + new Error("view_type is required when primary_dataset is set"), + { + statusCode: 400, + statusMessage: "view_type is required when primary_dataset is set", + }, + ); + } + + const { secondaryTable: secondaryDataset } = await fetchViewTables( + primaryDataset, + options.viewType, + ); + + if (secondaryDataset !== normalizedRequestedDataset) { + throw Object.assign( + new Error( + `Dataset "${normalizedRequestedDataset}" is not the secondary dataset for view (${primaryDataset}, ${options.viewType})`, + ), + { + statusCode: 403, + statusMessage: `Dataset "${normalizedRequestedDataset}" is not the configured secondary dataset`, + }, + ); + } + + return fetchTableConfig(primaryDataset, options.viewType); +}; + /** * Keeps public_views in sync with view config: add table if permission is anyone, remove otherwise. * @param tableName - The table name to sync. diff --git a/tests/unit/composables/useRecordCache.test.ts b/tests/unit/composables/useRecordCache.test.ts index 1ffb0c85..ad06934d 100644 --- a/tests/unit/composables/useRecordCache.test.ts +++ b/tests/unit/composables/useRecordCache.test.ts @@ -274,7 +274,7 @@ describe("useRecordCache - view_type threading", () => { }); }); - it("fetchRecord omits view_type when reading a different table (e.g. an alerts page's Mapeo table)", async () => { + it("fetchRecord identifies the view when reading its secondary dataset", async () => { mockRoute = { path: "/alerts/primary_alerts", params: { tablename: "primary_alerts" }, @@ -284,8 +284,12 @@ describe("useRecordCache - view_type threading", () => { const { fetchRecord } = useRecordCache(); await fetchRecord("mapeo_secondary", "abc"); - // No view type for a cross-table read → no options object at all. - expect(mockFetch).toHaveBeenCalledWith("/api/mapeo_secondary/abc"); + expect(mockFetch).toHaveBeenCalledWith("/api/mapeo_secondary/abc", { + query: { + view_type: "alerts", + primary_dataset: "primary_alerts", + }, + }); }); it("fetchRecords sends the route's view_type for its own dataset", async () => { diff --git a/tests/unit/composables/useViewType.test.ts b/tests/unit/composables/useViewType.test.ts index 8d697299..8c52fadc 100644 --- a/tests/unit/composables/useViewType.test.ts +++ b/tests/unit/composables/useViewType.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; -import { resolveViewTypeForTable } from "@/composables/useViewType"; +import { + resolveRecordFetchQuery, + resolveViewTypeForTable, +} from "@/composables/useViewType"; // resolveViewTypeForTable decides whether a data request should carry a // view_type, and which one. It encodes two deliberate guards that this suite @@ -69,3 +72,35 @@ describe("resolveViewTypeForTable", () => { ).toBeUndefined(); }); }); + +describe("resolveRecordFetchQuery", () => { + it("sends view_type for the route's own dataset", () => { + expect( + resolveRecordFetchQuery( + { path: "/alerts/springfield", params: { tablename: "springfield" } }, + "springfield", + ), + ).toEqual({ view_type: "alerts" }); + }); + + it("identifies the view when fetching its secondary dataset", () => { + expect( + resolveRecordFetchQuery( + { path: "/alerts/springfield", params: { tablename: "springfield" } }, + "mapeo_data", + ), + ).toEqual({ + view_type: "alerts", + primary_dataset: "springfield", + }); + }); + + it("returns an empty query off view routes", () => { + expect( + resolveRecordFetchQuery( + { path: "/dataset/springfield", params: { tablename: "springfield" } }, + "mapeo_data", + ), + ).toEqual({}); + }); +}); diff --git a/types/index.ts b/types/index.ts index bc061165..3b42ca6d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -125,6 +125,11 @@ export interface Views { export type ViewType = "alerts" | "map" | "gallery"; +export type RecordFetchQuery = { + view_type?: ViewType; + primary_dataset?: string; +}; + export const VIEW_TYPES = [ "alerts", "map",