Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions composables/useRecordCache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolveViewTypeForTable } from "@/composables/useViewType";
import { resolveRecordFetchQuery } from "@/composables/useViewType";
import { encodeDatasetNameForUrl } from "@/utils/identifierUtils";

import type { DataEntry } from "@/types";
Expand Down Expand Up @@ -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<DataEntry>(url, { query: { view_type: viewType } })
Object.keys(query).length > 0
? $fetch<DataEntry>(url, { query })
: $fetch<DataEntry>(url)
)
.then((record) => {
Expand Down Expand Up @@ -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<DataEntry[]>(`/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);
Expand Down
40 changes: 39 additions & 1 deletion composables/useViewType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ViewType } from "@/types";
import type { RecordFetchQuery, ViewType } from "@/types";
import { decodeDatasetNameFromUrl } from "@/utils/identifierUtils";

/**
Expand Down Expand Up @@ -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<string, unknown> }} route - Current page route.
* @param {string} requestedDataset - Dataset being fetched.
* @returns {RecordFetchQuery} Query object (possibly empty).
*/
export const resolveRecordFetchQuery = (
route: { path: string; params: Record<string, unknown> },
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,
};
};
17 changes: 14 additions & 3 deletions server/api/[table]/[recordId].get.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";
Expand Down
17 changes: 14 additions & 3 deletions server/api/[table]/records.post.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
57 changes: 57 additions & 0 deletions server/database/dbOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewConfig>} Config for the requested dataset's view.
*/
export const fetchViewConfigForDatasetRead = async (
requestedDataset: string,
options: {
viewType?: ViewType;
primaryDataset?: string | null;
} = {},
): Promise<ViewConfig> => {
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.
Expand Down
10 changes: 7 additions & 3 deletions tests/unit/composables/useRecordCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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 () => {
Expand Down
37 changes: 36 additions & 1 deletion tests/unit/composables/useViewType.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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({});
});
});
5 changes: 5 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading