Skip to content

Commit 0b8290e

Browse files
Implement Firebase Cloud Functions for byTag management.
1 parent 0243f0d commit 0b8290e

28 files changed

+221
-244
lines changed

database.rules.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
},
6666
"byTag": {
6767
".read": "true",
68-
".write": "true"
68+
".write": "false"
6969
}
7070
}
7171
}

eslint.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ import type {Linter} from 'eslint';
88

99
const config: Linter.Config[] = [
1010
{ignores: ['dist', 'build', '.llm/**']},
11+
{
12+
files: ['functions/**/*.js'],
13+
languageOptions: {
14+
ecmaVersion: 2020,
15+
globals: {
16+
...globals.node,
17+
},
18+
sourceType: 'commonjs',
19+
},
20+
rules: {
21+
...js.configs.recommended.rules,
22+
},
23+
},
1124
{
1225
files: ['public/**/*.{ts,tsx}'],
1326
languageOptions: {

firebase.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"database": {
33
"rules": "database.rules.json"
44
},
5+
"functions": {
6+
"source": "functions"
7+
},
58
"hosting": {
69
"public": "dist",
710
"rewrites": [

functions/.eslintrc.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module.exports = {
2+
root: true,
3+
env: {
4+
es6: true,
5+
node: true,
6+
commonjs: true,
7+
},
8+
extends: ['eslint:recommended'],
9+
parserOptions: {
10+
ecmaVersion: 2020,
11+
},
12+
rules: {
13+
quotes: ['error', 'double'],
14+
'object-curly-spacing': ['error', 'never'],
15+
indent: ['error', 2],
16+
},
17+
};

functions/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
npm-debug.log*
3+
yarn-debug.log*
4+
yarn-error.log*
5+
firebase-debug.log*
6+
firebase-debug.*.log*

functions/index.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
const functions = require('firebase-functions');
2+
const admin = require('firebase-admin');
3+
4+
admin.initializeApp();
5+
6+
const database = admin.database();
7+
8+
/**
9+
* Cloud function to update byTag when a blueprint is created
10+
*/
11+
exports.onBlueprintCreate = functions.database.ref('/blueprints/{blueprintId}').onCreate(async (snapshot, context) => {
12+
const blueprintId = context.params.blueprintId;
13+
const blueprint = snapshot.val();
14+
15+
if (!blueprint || !blueprint.tags || !Array.isArray(blueprint.tags)) {
16+
console.log(`Blueprint ${blueprintId} has no tags`);
17+
return null;
18+
}
19+
20+
const updates = {};
21+
blueprint.tags.forEach((tag) => {
22+
updates[`/byTag/${tag}/${blueprintId}`] = true;
23+
});
24+
25+
console.log(`Adding blueprint ${blueprintId} to ${blueprint.tags.length} tags`);
26+
return database.ref().update(updates);
27+
});
28+
29+
/**
30+
* Cloud function to update byTag when a blueprint is updated
31+
*/
32+
exports.onBlueprintUpdate = functions.database.ref('/blueprints/{blueprintId}').onUpdate(async (change, context) => {
33+
const blueprintId = context.params.blueprintId;
34+
const beforeData = change.before.val();
35+
const afterData = change.after.val();
36+
37+
// Handle case where blueprint might be soft-deleted
38+
if (!afterData) {
39+
console.log(`Blueprint ${blueprintId} was deleted`);
40+
return null;
41+
}
42+
43+
const beforeTags = beforeData?.tags || [];
44+
const afterTags = afterData?.tags || [];
45+
46+
// Find tags that were added and removed
47+
const addedTags = afterTags.filter((tag) => !beforeTags.includes(tag));
48+
const removedTags = beforeTags.filter((tag) => !afterTags.includes(tag));
49+
50+
if (addedTags.length === 0 && removedTags.length === 0) {
51+
console.log(`No tag changes for blueprint ${blueprintId}`);
52+
return null;
53+
}
54+
55+
const updates = {};
56+
57+
// Add blueprint to new tags
58+
addedTags.forEach((tag) => {
59+
updates[`/byTag/${tag}/${blueprintId}`] = true;
60+
});
61+
62+
// Remove blueprint from old tags
63+
removedTags.forEach((tag) => {
64+
updates[`/byTag/${tag}/${blueprintId}`] = null;
65+
});
66+
67+
console.log(`Updating tags for blueprint ${blueprintId}: +${addedTags.length} -${removedTags.length}`);
68+
return database.ref().update(updates);
69+
});
70+
71+
/**
72+
* Cloud function to update byTag when a blueprint is deleted
73+
*/
74+
exports.onBlueprintDelete = functions.database.ref('/blueprints/{blueprintId}').onDelete(async (snapshot, context) => {
75+
const blueprintId = context.params.blueprintId;
76+
const blueprint = snapshot.val();
77+
78+
if (!blueprint || !blueprint.tags || !Array.isArray(blueprint.tags)) {
79+
console.log(`Deleted blueprint ${blueprintId} had no tags`);
80+
return null;
81+
}
82+
83+
const updates = {};
84+
blueprint.tags.forEach((tag) => {
85+
updates[`/byTag/${tag}/${blueprintId}`] = null;
86+
});
87+
88+
console.log(`Removing blueprint ${blueprintId} from ${blueprint.tags.length} tags`);
89+
return database.ref().update(updates);
90+
});

functions/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "functions",
3+
"description": "Cloud Functions for Firebase",
4+
"scripts": {
5+
"lint": "eslint .",
6+
"serve": "firebase emulators:start --only functions",
7+
"shell": "firebase functions:shell",
8+
"start": "npm run shell",
9+
"deploy": "firebase deploy --only functions",
10+
"logs": "firebase functions:log"
11+
},
12+
"engines": {
13+
"node": "18"
14+
},
15+
"main": "index.js",
16+
"dependencies": {
17+
"firebase-admin": "^12.0.0",
18+
"firebase-functions": "^4.5.0"
19+
},
20+
"devDependencies": {
21+
"eslint": "^8.15.0",
22+
"firebase-functions-test": "^3.1.0"
23+
},
24+
"private": true
25+
}

src/Blueprint.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,8 @@ interface V15DecodedObject {
109109
[key: string]: unknown;
110110
}
111111

112-
// Union type for all possible decoded objects
113112
type DecodedObject = V15DecodedObject | undefined;
114113

115-
// Type for converted objects
116114
type ConvertedBlueprint = SingleBlueprint | BlueprintBook;
117115

118116
class Blueprint {

src/api/firebase.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ describe('firebase API', () => {
206206
const result = await fetchBlueprintFromCdn(mockBlueprintSummary);
207207

208208
expect(result).toBeNull();
209-
// Network errors (status 0) should not be logged
210209
expect(consoleWarnSpy).not.toHaveBeenCalled();
211210

212211
consoleWarnSpy.mockRestore();
@@ -294,9 +293,12 @@ describe('firebase API', () => {
294293

295294
const sortedByDateDesc = [...entries].sort((a, b) => (b.lastUpdatedDate || 0) - (a.lastUpdatedDate || 0));
296295

297-
expect(sortedByDateDesc[0].key).toBe('blueprint1'); // Newest (300) should be first
298-
expect(sortedByDateDesc[1].key).toBe('blueprint2'); // Second newest (200) should be second
299-
expect(sortedByDateDesc[2].key).toBe('blueprint3'); // Oldest (100) should be last
296+
// Newest (300) should be first
297+
expect(sortedByDateDesc[0].key).toBe('blueprint1');
298+
// Second newest (200) should be second
299+
expect(sortedByDateDesc[1].key).toBe('blueprint2');
300+
// Oldest (100) should be last
301+
expect(sortedByDateDesc[2].key).toBe('blueprint3');
300302

301303
expect(entries[0].key).toBe('blueprint1');
302304
expect(entries[1].key).toBe('blueprint2');

src/api/firebase.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,6 @@ export const fetchPaginatedSummaries = async (
568568
}
569569
}
570570

571-
// Build the data object from the reversed entries
572571
const data: Record<string, RawBlueprintSummary> = {};
573572
entries.forEach(([key, value]) => {
574573
data[key] = value;

0 commit comments

Comments
 (0)