Skip to content

Commit 3d5c389

Browse files
authored
feature/create-categories-endpoint: add API route, static fetch, typed transform and tests (#100)
1 parent 7082684 commit 3d5c389

File tree

11 files changed

+767
-414
lines changed

11 files changed

+767
-414
lines changed

package-lock.json

Lines changed: 401 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,24 @@
55
"type": "module",
66
"scripts": {
77
"dev": "next dev",
8-
"prebuild": "if [ \"$SKIP_FETCH\" != \"true\" ]; then npm run fetch:locations; else echo '⏭️ Skipping prebuild fetch'; fi",
9-
"build": "jest --config ./config/jest.config.cjs && npx playwright test --config=./config/playwright.config.ts && next build",
8+
9+
"prebuild": "node ./scripts/fetch-prebuild.js",
10+
11+
"build": "npm run test && npm run test:e2e && next build",
12+
1013
"start": "next start",
1114
"lint": "next lint",
15+
1216
"test": "jest --config ./config/jest.config.cjs",
1317
"test:watch": "jest --config ./config/jest.config.cjs --watch",
1418
"test:ci": "jest --config ./config/jest.config.cjs --ci",
1519
"test:e2e": "playwright test --config=./config/playwright.config.ts",
1620
"test:e2e:headed": "playwright test --config=./config/playwright.config.ts --headed",
1721
"test:e2e:debug": "playwright test --config=./config/playwright.config.ts --debug",
18-
"fetch:locations": "cross-env node scripts/fetch-locations.js"
22+
23+
"fetch:locations": "node ./scripts/fetch-locations.js",
24+
"fetch:service-categories": "node ./scripts/fetch-service-categories.js",
25+
"fetch:all": "npm-run-all fetch:locations fetch:service-categories"
1926
},
2027
"dependencies": {
2128
"@googlemaps/js-api-loader": "^1.16.8",
@@ -51,6 +58,7 @@
5158
"identity-obj-proxy": "^3.0.0",
5259
"jest": "^30.0.0-beta.3",
5360
"jest-environment-jsdom": "^30.0.0-beta.3",
61+
"npm-run-all": "^4.1.5",
5462
"playwright": "^1.52.0",
5563
"postcss": "^8.5.4",
5664
"tailwindcss": "^4.1.8",

scripts/fetch-prebuild.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// scripts/fetch-prebuild.js
2+
3+
if (process.env.SKIP_FETCH === 'true') {
4+
console.log('⏭️ Skipping prebuild fetch');
5+
process.exit(0);
6+
}
7+
8+
console.log('🔄 Fetching bootstrap data before build...');
9+
10+
// ESM: use dynamic import for node:child_process
11+
const { execSync } = await import('node:child_process');
12+
13+
execSync('npm run fetch:all', { stdio: 'inherit' });
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from 'fs';
2+
import { MongoClient } from 'mongodb';
3+
import dotenv from 'dotenv';
4+
5+
dotenv.config();
6+
7+
const OUTPUT_FILE = './src/data/service-categories.json';
8+
const MONGO_URI = process.env.MONGODB_URI;
9+
const MONGO_DB_NAME = process.env.MONGODB_DB || 'streetsupport';
10+
11+
if (!MONGO_URI) {
12+
throw new Error('❌ MONGODB_URI not found in environment');
13+
}
14+
15+
const client = new MongoClient(MONGO_URI);
16+
17+
const createKey = (name) =>
18+
name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '');
19+
20+
const formatCategory = (doc) => ({
21+
key: createKey(doc.Name || doc.name),
22+
name: doc.Name || doc.name,
23+
subCategories: (doc.SubCategories || []).map((sc) => ({
24+
key: sc.Key || sc.key,
25+
name: sc.Name || sc.name,
26+
})),
27+
});
28+
29+
(async () => {
30+
try {
31+
await client.connect();
32+
const db = client.db(MONGO_DB_NAME);
33+
34+
const raw = await db.collection('NestedServiceCategories').find({}).toArray();
35+
const formatted = raw.map(formatCategory);
36+
37+
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(formatted, null, 2));
38+
console.log(`✅ Saved to ${OUTPUT_FILE}`);
39+
40+
await client.close();
41+
process.exit(0);
42+
} catch (error) {
43+
console.error(`❌ Failed: ${error.message}`);
44+
process.exit(1);
45+
}
46+
})();

src/app/api/categories/route.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,35 @@
11
import { NextResponse } from 'next/server';
22
import { getClientPromise } from '@/utils/mongodb';
3+
import { formatCategory } from '@/utils/formatCategories';
4+
5+
interface RawCategory {
6+
key: string;
7+
name: string;
8+
subCategories: { key: string; name: string }[];
9+
}
10+
311

412
export async function GET() {
513
try {
614
const client = await getClientPromise();
715
const db = client.db('streetsupport');
816

9-
const categories = await db.collection('NestedServiceCategories').find({}).toArray();
17+
const categories = await db
18+
.collection<RawCategory>('NestedServiceCategories')
19+
.find({})
20+
.toArray();
1021

11-
const output = categories.map((cat) => ({
12-
key: cat.Key,
13-
name: cat.Name,
14-
subCategories: (cat.SubCategories || []).map((sub: any) => ({
15-
key: sub.Key,
16-
name: sub.Name,
17-
})),
18-
}));
22+
const formatted = categories.map(formatCategory);
1923

20-
return NextResponse.json({ status: 'success', data: output });
21-
} catch (error) {
22-
console.error(error);
2324
return NextResponse.json({
24-
status: 'error',
25-
message: error instanceof Error ? error.message : 'Unknown error',
25+
status: 'success',
26+
data: formatted,
2627
});
28+
} catch (error) {
29+
console.error('Error fetching categories:', error);
30+
return NextResponse.json(
31+
{ status: 'error', message: 'Failed to fetch categories' },
32+
{ status: 500 }
33+
);
2734
}
2835
}

src/components/FindHelp/FilterPanel.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,28 @@ interface Props {
1616
setSelectedSubCategory: (subCategory: string) => void;
1717
}
1818

19-
const categories = (rawCategories as Category[]).sort((a, b) => a.name.localeCompare(b.name));
19+
const categories = (rawCategories as Category[]).sort((a, b) =>
20+
a.name.localeCompare(b.name)
21+
);
2022

2123
export default function FilterPanel({
2224
selectedCategory,
2325
selectedSubCategory,
2426
setSelectedCategory,
2527
setSelectedSubCategory,
2628
}: Props) {
27-
const [subCategories, setSubCategories] = useState<{ key: string; name: string }[]>([]);
29+
const [subCategories, setSubCategories] = useState<
30+
{ key: string; name: string }[]
31+
>([]);
2832

2933
useEffect(() => {
3034
const matched = categories.find((cat) => cat.key === selectedCategory);
3135
if (matched && matched.subCategories) {
32-
setSubCategories([...matched.subCategories].sort((a, b) => a.name.localeCompare(b.name)));
36+
setSubCategories(
37+
[...matched.subCategories].sort((a, b) =>
38+
a.name.localeCompare(b.name)
39+
)
40+
);
3341
} else {
3442
setSubCategories([]);
3543
}
@@ -47,7 +55,7 @@ export default function FilterPanel({
4755
value={selectedCategory}
4856
onChange={(e) => {
4957
setSelectedCategory(e.target.value);
50-
setSelectedSubCategory(''); // reset subcategory when changing category
58+
setSelectedSubCategory('');
5159
}}
5260
>
5361
<option value="">All</option>

0 commit comments

Comments
 (0)