fix(housekeeping): address task 14 review round 2
This commit is contained in:
1 parent
d09eaa33d6
commit
c524b305d7
17 files changed
+614
-130
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type ContentRouteId, contentRouteGroup } from "../routes";
|
||||
import type {
|
||||
ContentQueryData,
|
||||
@@ -18,102 +19,102 @@ interface QueryDefinition {
|
||||
export const CONTENT_QUERY_DEFINITIONS = {
|
||||
"content.editorial.articles": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.article-detail": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.tags": {
|
||||
statement:
|
||||
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name",
|
||||
href: "/ase/content/editorial/tags",
|
||||
},
|
||||
"content.editorial.writeable-boxes": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id",
|
||||
href: "/ase/content/editorial/writeable-boxes",
|
||||
},
|
||||
"content.media.photos": {
|
||||
statement:
|
||||
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC LIMIT 500",
|
||||
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC",
|
||||
href: "/ase/content/media/photos",
|
||||
},
|
||||
"content.media.banners": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id LIMIT 500",
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id",
|
||||
href: "/ase/content/media/banners",
|
||||
},
|
||||
"content.media.ads": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.media.ad-detail": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.events": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-types": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
|
||||
href: "/ase/content/engagement/events/types",
|
||||
},
|
||||
"content.engagement.polls": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.poll-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.prefixes": {
|
||||
statement:
|
||||
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC LIMIT 500",
|
||||
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC",
|
||||
href: "/ase/content/engagement/prefixes",
|
||||
},
|
||||
"content.help.questions": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.question-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.email-templates": {
|
||||
statement:
|
||||
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name",
|
||||
href: "/ase/content/help/email-templates",
|
||||
},
|
||||
"content.localization.emulator": {
|
||||
statement:
|
||||
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key LIMIT 500",
|
||||
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key",
|
||||
href: "/ase/content/localization/emulator",
|
||||
},
|
||||
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
|
||||
@@ -161,7 +162,7 @@ function response(
|
||||
};
|
||||
}
|
||||
|
||||
function matches(
|
||||
function matchesListSearch(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
@@ -174,28 +175,38 @@ function matches(
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
input: NormalizedContentQueryInput,
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly ContentQueryItem[] {
|
||||
let items = rawRows
|
||||
.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
})
|
||||
.filter((item) => matches(input, item));
|
||||
if (input.params.id) {
|
||||
items = items.filter((item) => item.id === input.params.id);
|
||||
}
|
||||
return items;
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function statementParts(statement: string): {
|
||||
readonly selection: string;
|
||||
readonly ordering: string;
|
||||
} {
|
||||
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
|
||||
return match
|
||||
? { selection: match[1], ordering: match[2] }
|
||||
: { selection: statement, ordering: "" };
|
||||
}
|
||||
|
||||
function totalFromRows(rawRows: readonly RawRow[]): number {
|
||||
const total = Number(rawRows[0]?.total ?? 0);
|
||||
if (!Number.isSafeInteger(total) || total < 0)
|
||||
throw new Error("invalid Content query total");
|
||||
return total;
|
||||
}
|
||||
|
||||
async function databaseRoute(
|
||||
@@ -206,16 +217,41 @@ async function databaseRoute(
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const items = mapRows(
|
||||
input,
|
||||
definition,
|
||||
const { selection, ordering } = statementParts(definition.statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
/\bAS\s+description\b/iu.test(selection)
|
||||
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
: sql.raw(selection);
|
||||
const total = totalFromRows(
|
||||
rows(
|
||||
await db.execute(
|
||||
sql.raw(definition.statement.replace(/\s+LIMIT 500$/u, "")),
|
||||
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS content_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return response(input, items, items.length);
|
||||
const items = mapRows(
|
||||
definition,
|
||||
rows(
|
||||
await db.execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaLibrary(
|
||||
@@ -252,7 +288,7 @@ async function mediaLibrary(
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase/content/media/library",
|
||||
}))
|
||||
.filter((item) => matches(input, item));
|
||||
.filter((item) => matchesListSearch(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,23 @@ import {
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
const queryMocks = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
const queryMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
join: vi.fn((chunks: unknown[], separator: unknown) => ({
|
||||
chunks,
|
||||
separator,
|
||||
})),
|
||||
raw: vi.fn((statement: string) => ({ statement })),
|
||||
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: Array.from(strings),
|
||||
values,
|
||||
})),
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
sql: { raw: (statement: string) => statement },
|
||||
sql: Object.assign(queryMocks.tagged, {
|
||||
join: queryMocks.join,
|
||||
raw: queryMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
@@ -45,15 +59,15 @@ const ready: ContentQueryData = {
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const allRows = Array.from({ length: 600 }, (_, index) => ({
|
||||
const pageRows = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
}));
|
||||
queryMocks.execute.mockImplementationOnce(async (statement: string) => [
|
||||
statement.includes("LIMIT 500") ? allRows.slice(0, 500) : allRows,
|
||||
]);
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 600 }]])
|
||||
.mockResolvedValueOnce([pageRows]);
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
@@ -61,6 +75,41 @@ describe("Content query", () => {
|
||||
});
|
||||
expect(result.total).toBe(600);
|
||||
expect(result.items).toHaveLength(25);
|
||||
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
||||
const pageQuery = queryMocks.execute.mock.calls[1]?.[0];
|
||||
expect(JSON.stringify(pageQuery)).toContain("25");
|
||||
expect(JSON.stringify(pageQuery)).toContain("0");
|
||||
});
|
||||
|
||||
it("binds search, limit, and offset into the bounded page query", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[{ id: 9, title: "Launch", status: "published", updated_at: null }],
|
||||
]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "Launch", pageSize: 7, offset: 14 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
||||
expect(serialized).toContain("%launch%");
|
||||
expect(serialized).toContain("7");
|
||||
expect(serialized).toContain("14");
|
||||
});
|
||||
|
||||
it("searches only projected aliases for routes without descriptions", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 0 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.engagement.events",
|
||||
params: {},
|
||||
list: { search: "launch", pageSize: 25, offset: 0 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
||||
expect(serialized).toContain("COALESCE(title");
|
||||
expect(serialized).not.toContain("COALESCE(description");
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
|
||||
Reference in new issue
Block a user