@cravery/firebase 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/iam/converters.d.ts +4 -0
- package/dist/iam/converters.d.ts.map +1 -0
- package/dist/iam/converters.js +39 -0
- package/dist/iam/converters.js.map +1 -0
- package/dist/iam/index.d.ts +2 -0
- package/dist/iam/index.d.ts.map +1 -0
- package/dist/iam/index.js +18 -0
- package/dist/iam/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/recipe/converters.d.ts +5 -0
- package/dist/recipe/converters.d.ts.map +1 -0
- package/dist/recipe/converters.js +90 -0
- package/dist/recipe/converters.js.map +1 -0
- package/dist/recipe/index.d.ts +4 -0
- package/dist/recipe/index.d.ts.map +1 -0
- package/dist/recipe/index.js +20 -0
- package/dist/recipe/index.js.map +1 -0
- package/dist/recipe/repository.d.ts +21 -0
- package/dist/recipe/repository.d.ts.map +1 -0
- package/dist/recipe/repository.js +157 -0
- package/dist/recipe/repository.js.map +1 -0
- package/dist/recipe/utils.d.ts +7 -0
- package/dist/recipe/utils.d.ts.map +1 -0
- package/dist/recipe/utils.js +118 -0
- package/dist/recipe/utils.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/strip-undefined.d.ts +13 -0
- package/dist/utils/strip-undefined.d.ts.map +1 -0
- package/dist/utils/strip-undefined.js +33 -0
- package/dist/utils/strip-undefined.js.map +1 -0
- package/dist/utils/timestamp.d.ts +6 -0
- package/dist/utils/timestamp.d.ts.map +1 -0
- package/dist/utils/timestamp.js +19 -0
- package/dist/utils/timestamp.js.map +1 -0
- package/package.json +58 -0
- package/src/iam/converters.ts +38 -0
- package/src/iam/index.ts +1 -0
- package/src/index.ts +3 -0
- package/src/recipe/converters.ts +93 -0
- package/src/recipe/index.ts +3 -0
- package/src/recipe/repository.ts +220 -0
- package/src/recipe/utils.ts +143 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/strip-undefined.ts +32 -0
- package/src/utils/timestamp.ts +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively removes undefined values from an object or array.
|
|
3
|
+
* This is necessary because Firestore rejects undefined values.
|
|
4
|
+
*
|
|
5
|
+
* @param obj - The object or array to clean
|
|
6
|
+
* @returns A new object/array with undefined values removed
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* stripUndefined({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
|
|
10
|
+
* // Returns: { a: 1, c: { e: 2 } }
|
|
11
|
+
*/
|
|
12
|
+
export declare function stripUndefined<T>(obj: T): T;
|
|
13
|
+
//# sourceMappingURL=strip-undefined.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strip-undefined.d.ts","sourceRoot":"","sources":["../../src/utils/strip-undefined.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,CAoB3C"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stripUndefined = stripUndefined;
|
|
4
|
+
/**
|
|
5
|
+
* Recursively removes undefined values from an object or array.
|
|
6
|
+
* This is necessary because Firestore rejects undefined values.
|
|
7
|
+
*
|
|
8
|
+
* @param obj - The object or array to clean
|
|
9
|
+
* @returns A new object/array with undefined values removed
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* stripUndefined({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
|
|
13
|
+
* // Returns: { a: 1, c: { e: 2 } }
|
|
14
|
+
*/
|
|
15
|
+
function stripUndefined(obj) {
|
|
16
|
+
if (obj === null || obj === undefined) {
|
|
17
|
+
return obj;
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(obj)) {
|
|
20
|
+
return obj.map(item => stripUndefined(item));
|
|
21
|
+
}
|
|
22
|
+
if (typeof obj === 'object') {
|
|
23
|
+
const cleaned = {};
|
|
24
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
25
|
+
if (value !== undefined) {
|
|
26
|
+
cleaned[key] = stripUndefined(value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return cleaned;
|
|
30
|
+
}
|
|
31
|
+
return obj;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=strip-undefined.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strip-undefined.js","sourceRoot":"","sources":["../../src/utils/strip-undefined.ts"],"names":[],"mappings":";;AAWA,wCAoBC;AA/BD;;;;;;;;;;GAUG;AACH,SAAgB,cAAc,CAAI,GAAM;IACtC,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtC,OAAO,GAAG,CAAC;IACb,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACvB,OAAO,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,CAAM,CAAC;IACpD,CAAC;IAED,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,MAAM,OAAO,GAAQ,EAAE,CAAC;QACxB,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YAC/C,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO,CAAC,GAAG,CAAC,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;YACvC,CAAC;QACH,CAAC;QACD,OAAO,OAAY,CAAC;IACtB,CAAC;IAED,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
|
|
2
|
+
import type { Timestamp } from "@cravery/core/types";
|
|
3
|
+
export declare function toTimestamp(timestamp: FirestoreTimestamp): Timestamp;
|
|
4
|
+
export declare function fromTimestamp(timestamp: Timestamp): FirestoreTimestamp;
|
|
5
|
+
export declare function toOptionalTimestamp(timestamp?: FirestoreTimestamp): Timestamp | undefined;
|
|
6
|
+
//# sourceMappingURL=timestamp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timestamp.d.ts","sourceRoot":"","sources":["../../src/utils/timestamp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,IAAI,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,wBAAgB,WAAW,CAAC,SAAS,EAAE,kBAAkB,GAAG,SAAS,CAKpE;AAED,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,GAAG,kBAAkB,CAItE;AAED,wBAAgB,mBAAmB,CACjC,SAAS,CAAC,EAAE,kBAAkB,GAC7B,SAAS,GAAG,SAAS,CAEvB"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toTimestamp = toTimestamp;
|
|
4
|
+
exports.fromTimestamp = fromTimestamp;
|
|
5
|
+
exports.toOptionalTimestamp = toOptionalTimestamp;
|
|
6
|
+
const firestore_1 = require("firebase-admin/firestore");
|
|
7
|
+
function toTimestamp(timestamp) {
|
|
8
|
+
return {
|
|
9
|
+
seconds: timestamp.seconds,
|
|
10
|
+
nanoseconds: timestamp.nanoseconds,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function fromTimestamp(timestamp) {
|
|
14
|
+
return firestore_1.Timestamp.fromMillis(timestamp.seconds * 1000 + timestamp.nanoseconds / 1000000);
|
|
15
|
+
}
|
|
16
|
+
function toOptionalTimestamp(timestamp) {
|
|
17
|
+
return timestamp ? toTimestamp(timestamp) : undefined;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=timestamp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timestamp.js","sourceRoot":"","sources":["../../src/utils/timestamp.ts"],"names":[],"mappings":";;AAGA,kCAKC;AAED,sCAIC;AAED,kDAIC;AApBD,wDAA2E;AAG3E,SAAgB,WAAW,CAAC,SAA6B;IACvD,OAAO;QACL,OAAO,EAAE,SAAS,CAAC,OAAO;QAC1B,WAAW,EAAE,SAAS,CAAC,WAAW;KACnC,CAAC;AACJ,CAAC;AAED,SAAgB,aAAa,CAAC,SAAoB;IAChD,OAAO,qBAAkB,CAAC,UAAU,CAClC,SAAS,CAAC,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC,WAAW,GAAG,OAAO,CAC3D,CAAC;AACJ,CAAC;AAED,SAAgB,mBAAmB,CACjC,SAA8B;IAE9B,OAAO,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACxD,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cravery/firebase",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Shared Firebase Admin SDK utilities for Cravery",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./recipe": {
|
|
13
|
+
"types": "./dist/recipe/index.d.ts",
|
|
14
|
+
"default": "./dist/recipe/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./iam": {
|
|
17
|
+
"types": "./dist/iam/index.d.ts",
|
|
18
|
+
"default": "./dist/iam/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./utils": {
|
|
21
|
+
"types": "./dist/utils/index.d.ts",
|
|
22
|
+
"default": "./dist/utils/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"typesVersions": {
|
|
26
|
+
"*": {
|
|
27
|
+
"recipe": [
|
|
28
|
+
"dist/recipe/index.d.ts"
|
|
29
|
+
],
|
|
30
|
+
"iam": [
|
|
31
|
+
"dist/iam/index.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"utils": [
|
|
34
|
+
"dist/utils/index.d.ts"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"build:watch": "tsc --watch",
|
|
41
|
+
"clean": "rimraf dist && npm run build"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [],
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@cravery/core": "^0.0.29",
|
|
46
|
+
"firebase-admin": "^13.6.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@cravery/core": "^0.0.29",
|
|
50
|
+
"firebase-admin": "^13.6.0",
|
|
51
|
+
"rimraf": "^6.1.2",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist/**/*",
|
|
56
|
+
"src/**/*"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FirestoreDataConverter,
|
|
3
|
+
QueryDocumentSnapshot,
|
|
4
|
+
DocumentData,
|
|
5
|
+
} from "firebase-admin/firestore";
|
|
6
|
+
import type { User } from "@cravery/core/types";
|
|
7
|
+
import { toTimestamp, toOptionalTimestamp, fromTimestamp } from "../utils";
|
|
8
|
+
|
|
9
|
+
export const userConverter: FirestoreDataConverter<User> = {
|
|
10
|
+
toFirestore(user: User): DocumentData {
|
|
11
|
+
const { id, createdAt, updatedAt, deletedAt, ...rest } = user;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
...rest,
|
|
15
|
+
createdAt: fromTimestamp(createdAt),
|
|
16
|
+
updatedAt: fromTimestamp(updatedAt),
|
|
17
|
+
...(deletedAt && { deletedAt: fromTimestamp(deletedAt) }),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
fromFirestore(snapshot: QueryDocumentSnapshot): User {
|
|
22
|
+
const data = snapshot.data();
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
id: snapshot.id,
|
|
26
|
+
email: data.email,
|
|
27
|
+
name: data.name,
|
|
28
|
+
role: data.role,
|
|
29
|
+
status: data.status,
|
|
30
|
+
verified: data.verified ?? false,
|
|
31
|
+
createdAt: toTimestamp(data.createdAt),
|
|
32
|
+
updatedAt: toTimestamp(data.updatedAt),
|
|
33
|
+
deletedAt: toOptionalTimestamp(data.deletedAt),
|
|
34
|
+
imageUrl: data.imageUrl,
|
|
35
|
+
subscriptionId: data.subscriptionId,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
package/src/iam/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./converters";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FirestoreDataConverter,
|
|
3
|
+
QueryDocumentSnapshot,
|
|
4
|
+
DocumentData,
|
|
5
|
+
} from "firebase-admin/firestore";
|
|
6
|
+
import type { RecipeMeta, RecipeContent } from "@cravery/core/types";
|
|
7
|
+
import { toTimestamp, toOptionalTimestamp, fromTimestamp, stripUndefined } from "../utils";
|
|
8
|
+
|
|
9
|
+
export const recipeMetaConverter: FirestoreDataConverter<RecipeMeta> = {
|
|
10
|
+
toFirestore(recipeMeta: RecipeMeta): DocumentData {
|
|
11
|
+
const { id, createdAt, updatedAt, deletedAt, ...rest } = recipeMeta;
|
|
12
|
+
|
|
13
|
+
const convertTimestamp = (ts: any) => {
|
|
14
|
+
if (!ts) return ts;
|
|
15
|
+
// Handle Firestore internal format with _seconds
|
|
16
|
+
if (typeof ts === 'object' && '_seconds' in ts) {
|
|
17
|
+
return ts;
|
|
18
|
+
}
|
|
19
|
+
// Handle Firestore Timestamp objects with seconds/nanoseconds
|
|
20
|
+
if (typeof ts === 'object' && 'seconds' in ts && 'nanoseconds' in ts) {
|
|
21
|
+
return fromTimestamp(ts);
|
|
22
|
+
}
|
|
23
|
+
return ts;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Strip undefined values before returning to Firestore
|
|
27
|
+
return stripUndefined({
|
|
28
|
+
...rest,
|
|
29
|
+
createdAt: convertTimestamp(createdAt),
|
|
30
|
+
updatedAt: convertTimestamp(updatedAt),
|
|
31
|
+
...(deletedAt && { deletedAt: convertTimestamp(deletedAt) }),
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
fromFirestore(snapshot: QueryDocumentSnapshot): RecipeMeta {
|
|
36
|
+
const data = snapshot.data();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: snapshot.id,
|
|
40
|
+
allergens: data.allergens,
|
|
41
|
+
confidence: data.confidence,
|
|
42
|
+
createdBy: data.createdBy,
|
|
43
|
+
cuisine: data.cuisine,
|
|
44
|
+
dietaryTags: data.dietaryTags,
|
|
45
|
+
difficulty: data.difficulty,
|
|
46
|
+
equipment: data.equipment,
|
|
47
|
+
imageUrl: data.imageUrl,
|
|
48
|
+
ingredientSections: data.ingredientSections,
|
|
49
|
+
instructions: data.instructions,
|
|
50
|
+
mealTypes: data.mealTypes,
|
|
51
|
+
nutrition: data.nutrition,
|
|
52
|
+
originalLocale: data.originalLocale,
|
|
53
|
+
servings: data.servings,
|
|
54
|
+
source: data.source,
|
|
55
|
+
sourceUrl: data.sourceUrl,
|
|
56
|
+
spiciness: data.spiciness,
|
|
57
|
+
status: data.status,
|
|
58
|
+
time: data.time,
|
|
59
|
+
createdAt: toTimestamp(data.createdAt),
|
|
60
|
+
updatedAt: toTimestamp(data.updatedAt),
|
|
61
|
+
deletedAt: toOptionalTimestamp(data.deletedAt),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const recipeContentConverter: FirestoreDataConverter<RecipeContent> = {
|
|
67
|
+
toFirestore(content: RecipeContent): DocumentData {
|
|
68
|
+
// Strip undefined values before returning to Firestore
|
|
69
|
+
return stripUndefined({
|
|
70
|
+
description: content.description,
|
|
71
|
+
equipment: content.equipment,
|
|
72
|
+
ingredientSections: content.ingredientSections,
|
|
73
|
+
instructions: content.instructions,
|
|
74
|
+
locale: content.locale,
|
|
75
|
+
tips: content.tips,
|
|
76
|
+
title: content.title,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
fromFirestore(snapshot: QueryDocumentSnapshot): RecipeContent {
|
|
81
|
+
const data = snapshot.data();
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
description: data.description,
|
|
85
|
+
equipment: data.equipment,
|
|
86
|
+
ingredientSections: data.ingredientSections,
|
|
87
|
+
instructions: data.instructions,
|
|
88
|
+
locale: data.locale,
|
|
89
|
+
tips: data.tips,
|
|
90
|
+
title: data.title,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Firestore, Timestamp } from "firebase-admin/firestore";
|
|
2
|
+
import type {
|
|
3
|
+
Recipe,
|
|
4
|
+
RecipeMeta,
|
|
5
|
+
RecipeContent,
|
|
6
|
+
Locale,
|
|
7
|
+
RecipeStatus,
|
|
8
|
+
} from "@cravery/core";
|
|
9
|
+
import { recipeMetaConverter, recipeContentConverter } from "./converters";
|
|
10
|
+
import { mergeRecipe, splitRecipe } from "./utils";
|
|
11
|
+
|
|
12
|
+
const RECIPES_COLLECTION = "recipes";
|
|
13
|
+
const CONTENT_SUBCOLLECTION = "content";
|
|
14
|
+
|
|
15
|
+
export class RecipeRepository {
|
|
16
|
+
constructor(private db: Firestore) {}
|
|
17
|
+
|
|
18
|
+
private get metaCollection() {
|
|
19
|
+
return this.db
|
|
20
|
+
.collection(RECIPES_COLLECTION)
|
|
21
|
+
.withConverter(recipeMetaConverter);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private contentCollection(recipeId: string) {
|
|
25
|
+
return this.db
|
|
26
|
+
.collection(RECIPES_COLLECTION)
|
|
27
|
+
.doc(recipeId)
|
|
28
|
+
.collection(CONTENT_SUBCOLLECTION)
|
|
29
|
+
.withConverter(recipeContentConverter);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async create(recipe: Omit<Recipe, "id">): Promise<Recipe> {
|
|
33
|
+
const { meta, content } = splitRecipe(recipe as Recipe);
|
|
34
|
+
const metaRef = await this.metaCollection.add(meta as RecipeMeta);
|
|
35
|
+
await this.contentCollection(metaRef.id).doc(content.locale).set(content);
|
|
36
|
+
return this.getById(metaRef.id, content.locale);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getById(recipeId: string, locale: Locale): Promise<Recipe> {
|
|
40
|
+
const [metaDoc, contentDoc] = await Promise.all([
|
|
41
|
+
this.metaCollection.doc(recipeId).get(),
|
|
42
|
+
this.contentCollection(recipeId).doc(locale).get(),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
if (!metaDoc.exists) {
|
|
46
|
+
throw new Error(`Recipe ${recipeId} not found`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const meta = metaDoc.data()!;
|
|
50
|
+
|
|
51
|
+
let content: RecipeContent;
|
|
52
|
+
if (contentDoc.exists) {
|
|
53
|
+
content = contentDoc.data()!;
|
|
54
|
+
} else {
|
|
55
|
+
const originalContentDoc = await this.contentCollection(recipeId)
|
|
56
|
+
.doc(meta.originalLocale)
|
|
57
|
+
.get();
|
|
58
|
+
if (!originalContentDoc.exists) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Recipe ${recipeId} has no content for locale ${locale} or ${meta.originalLocale}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
content = originalContentDoc.data()!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return mergeRecipe(meta, content);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async updateMeta(
|
|
70
|
+
recipeId: string,
|
|
71
|
+
updates: Partial<Omit<RecipeMeta, "id" | "createdAt" | "createdBy">>,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
await this.metaCollection.doc(recipeId).update({
|
|
74
|
+
...updates,
|
|
75
|
+
updatedAt: Timestamp.now(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async updateContent(
|
|
80
|
+
recipeId: string,
|
|
81
|
+
locale: Locale,
|
|
82
|
+
content: Partial<RecipeContent>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
await this.contentCollection(recipeId)
|
|
85
|
+
.doc(locale)
|
|
86
|
+
.set(
|
|
87
|
+
{
|
|
88
|
+
...content,
|
|
89
|
+
locale,
|
|
90
|
+
},
|
|
91
|
+
{ merge: true },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async getAvailableLocales(recipeId: string): Promise<Locale[]> {
|
|
96
|
+
const snapshot = await this.contentCollection(recipeId).get();
|
|
97
|
+
return snapshot.docs.map((doc) => doc.id as Locale);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async delete(recipeId: string): Promise<void> {
|
|
101
|
+
await this.metaCollection.doc(recipeId).update({
|
|
102
|
+
status: "deleted",
|
|
103
|
+
deletedAt: Timestamp.now(),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getByStatus(
|
|
108
|
+
status: RecipeStatus,
|
|
109
|
+
locale: Locale,
|
|
110
|
+
limit = 10,
|
|
111
|
+
): Promise<Recipe[]> {
|
|
112
|
+
// Query only by status - deletedAt field may not exist for newly created recipes
|
|
113
|
+
const metaSnapshot = await this.metaCollection
|
|
114
|
+
.where("status", "==", status)
|
|
115
|
+
.limit(limit)
|
|
116
|
+
.get();
|
|
117
|
+
|
|
118
|
+
// Filter out deleted recipes client-side (where deletedAt exists and is not null)
|
|
119
|
+
const nonDeletedDocs = metaSnapshot.docs.filter(doc => !doc.data().deletedAt);
|
|
120
|
+
|
|
121
|
+
const recipes = await Promise.all(
|
|
122
|
+
nonDeletedDocs.map(async (metaDoc) => {
|
|
123
|
+
const meta = metaDoc.data();
|
|
124
|
+
const contentDoc = await this.contentCollection(metaDoc.id)
|
|
125
|
+
.doc(locale)
|
|
126
|
+
.get();
|
|
127
|
+
|
|
128
|
+
let content: RecipeContent;
|
|
129
|
+
if (contentDoc.exists) {
|
|
130
|
+
content = contentDoc.data()!;
|
|
131
|
+
} else {
|
|
132
|
+
const originalContentDoc = await this.contentCollection(metaDoc.id)
|
|
133
|
+
.doc(meta.originalLocale)
|
|
134
|
+
.get();
|
|
135
|
+
content = originalContentDoc.data()!;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return mergeRecipe(meta, content);
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return recipes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getByMultipleStatuses(
|
|
146
|
+
statuses: RecipeStatus[],
|
|
147
|
+
locale: Locale,
|
|
148
|
+
page = 1,
|
|
149
|
+
limit = 20,
|
|
150
|
+
): Promise<{ recipes: Recipe[]; total: number }> {
|
|
151
|
+
// Build base query - no deletedAt filter on query level
|
|
152
|
+
const baseQuery = this.metaCollection.where("status", "in", statuses);
|
|
153
|
+
|
|
154
|
+
// Get all documents for accurate count
|
|
155
|
+
const allDocsSnapshot = await baseQuery.get();
|
|
156
|
+
|
|
157
|
+
// Client-side filtering for deletedAt
|
|
158
|
+
const nonDeletedDocs = allDocsSnapshot.docs.filter(doc => !doc.data().deletedAt);
|
|
159
|
+
|
|
160
|
+
// Sort by createdAt desc
|
|
161
|
+
const sortedDocs = nonDeletedDocs.sort((a, b) => {
|
|
162
|
+
const aTime = a.data().createdAt;
|
|
163
|
+
const bTime = b.data().createdAt;
|
|
164
|
+
return bTime.seconds - aTime.seconds;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const total = sortedDocs.length;
|
|
168
|
+
const offset = (page - 1) * limit;
|
|
169
|
+
const paginatedDocs = sortedDocs.slice(offset, offset + limit);
|
|
170
|
+
|
|
171
|
+
const recipes = await Promise.all(
|
|
172
|
+
paginatedDocs.map(async (metaDoc) => {
|
|
173
|
+
const meta = metaDoc.data();
|
|
174
|
+
const contentDoc = await this.contentCollection(metaDoc.id)
|
|
175
|
+
.doc(locale)
|
|
176
|
+
.get();
|
|
177
|
+
|
|
178
|
+
let content: RecipeContent;
|
|
179
|
+
if (contentDoc.exists) {
|
|
180
|
+
content = contentDoc.data()!;
|
|
181
|
+
} else {
|
|
182
|
+
const originalContentDoc = await this.contentCollection(metaDoc.id)
|
|
183
|
+
.doc(meta.originalLocale)
|
|
184
|
+
.get();
|
|
185
|
+
content = originalContentDoc.data()!;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return mergeRecipe(meta, content);
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return { recipes, total };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async findBySourceUrl(sourceUrl: string): Promise<Recipe | null> {
|
|
196
|
+
const metaSnapshot = await this.metaCollection
|
|
197
|
+
.where("sourceUrl", "==", sourceUrl)
|
|
198
|
+
.where("deletedAt", "==", null)
|
|
199
|
+
.limit(1)
|
|
200
|
+
.get();
|
|
201
|
+
|
|
202
|
+
if (metaSnapshot.empty) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const metaDoc = metaSnapshot.docs[0];
|
|
207
|
+
const meta = metaDoc.data();
|
|
208
|
+
|
|
209
|
+
const contentDoc = await this.contentCollection(metaDoc.id)
|
|
210
|
+
.doc(meta.originalLocale)
|
|
211
|
+
.get();
|
|
212
|
+
|
|
213
|
+
if (!contentDoc.exists) {
|
|
214
|
+
throw new Error(`Recipe ${metaDoc.id} has no content`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const content = contentDoc.data()!;
|
|
218
|
+
return mergeRecipe(meta, content);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Recipe, RecipeContent, RecipeMeta } from "@cravery/core";
|
|
2
|
+
|
|
3
|
+
export function mergeRecipe(meta: RecipeMeta, content: RecipeContent): Recipe {
|
|
4
|
+
const equipment = meta.equipment?.map((equipMeta, index) => {
|
|
5
|
+
const equipContent = content.equipment?.[index];
|
|
6
|
+
if (!equipContent) {
|
|
7
|
+
throw new Error(`Missing equipment content at index ${index}`);
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
...equipMeta,
|
|
11
|
+
...equipContent,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ingredientSections = meta.ingredientSections.map(
|
|
16
|
+
(sectionMeta, sectionIndex) => ({
|
|
17
|
+
slug: sectionMeta.slug,
|
|
18
|
+
title: content.ingredientSections[sectionIndex]?.title,
|
|
19
|
+
ingredients: sectionMeta.ingredients.map((ingredMeta, ingredIndex) => ({
|
|
20
|
+
...ingredMeta,
|
|
21
|
+
...(content.ingredientSections[sectionIndex]?.ingredients[
|
|
22
|
+
ingredIndex
|
|
23
|
+
] || {}),
|
|
24
|
+
})),
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const instructions = meta.instructions.map((instrMeta, index) => ({
|
|
29
|
+
...instrMeta,
|
|
30
|
+
...(content.instructions[index] || {}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
id: meta.id,
|
|
35
|
+
allergens: meta.allergens,
|
|
36
|
+
confidence: meta.confidence,
|
|
37
|
+
createdAt: meta.createdAt,
|
|
38
|
+
createdBy: meta.createdBy,
|
|
39
|
+
cuisine: meta.cuisine,
|
|
40
|
+
deletedAt: meta.deletedAt,
|
|
41
|
+
dietaryTags: meta.dietaryTags,
|
|
42
|
+
difficulty: meta.difficulty,
|
|
43
|
+
imageUrl: meta.imageUrl,
|
|
44
|
+
mealTypes: meta.mealTypes,
|
|
45
|
+
nutrition: meta.nutrition,
|
|
46
|
+
originalLocale: meta.originalLocale,
|
|
47
|
+
servings: meta.servings,
|
|
48
|
+
source: meta.source,
|
|
49
|
+
sourceUrl: meta.sourceUrl,
|
|
50
|
+
spiciness: meta.spiciness,
|
|
51
|
+
status: meta.status,
|
|
52
|
+
time: meta.time,
|
|
53
|
+
updatedAt: meta.updatedAt,
|
|
54
|
+
equipment,
|
|
55
|
+
ingredientSections,
|
|
56
|
+
instructions,
|
|
57
|
+
description: content.description,
|
|
58
|
+
locale: content.locale,
|
|
59
|
+
tips: content.tips,
|
|
60
|
+
title: content.title,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function splitRecipe(recipe: Recipe): {
|
|
65
|
+
meta: Omit<RecipeMeta, "id">;
|
|
66
|
+
content: RecipeContent;
|
|
67
|
+
} {
|
|
68
|
+
const {
|
|
69
|
+
id,
|
|
70
|
+
description,
|
|
71
|
+
equipment,
|
|
72
|
+
ingredientSections,
|
|
73
|
+
instructions,
|
|
74
|
+
locale,
|
|
75
|
+
tips,
|
|
76
|
+
title,
|
|
77
|
+
...metaFields
|
|
78
|
+
} = recipe;
|
|
79
|
+
|
|
80
|
+
const equipmentMeta = equipment?.map(({ required, slug }) => ({
|
|
81
|
+
required,
|
|
82
|
+
slug,
|
|
83
|
+
}));
|
|
84
|
+
const equipmentContent = equipment?.map(({ name, notes, slug }) => ({
|
|
85
|
+
name,
|
|
86
|
+
notes,
|
|
87
|
+
slug,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
const ingredientSectionsMeta = ingredientSections.map(
|
|
91
|
+
({ ingredients, slug }) => ({
|
|
92
|
+
slug,
|
|
93
|
+
ingredients: ingredients.map(({ quantity, required, slug, unit }) => ({
|
|
94
|
+
quantity,
|
|
95
|
+
required,
|
|
96
|
+
slug,
|
|
97
|
+
unit,
|
|
98
|
+
})),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const ingredientSectionsContent = ingredientSections.map(
|
|
102
|
+
({ ingredients, slug, title }) => ({
|
|
103
|
+
slug,
|
|
104
|
+
title,
|
|
105
|
+
ingredients: ingredients.map(({ name, notes, slug }) => ({
|
|
106
|
+
name,
|
|
107
|
+
notes,
|
|
108
|
+
slug,
|
|
109
|
+
})),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const instructionsMeta = instructions.map(
|
|
114
|
+
({ duration, step, temperature }) => ({
|
|
115
|
+
duration,
|
|
116
|
+
step,
|
|
117
|
+
temperature,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
const instructionsContent = instructions.map(({ step, text }) => ({
|
|
121
|
+
step,
|
|
122
|
+
text,
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const content: RecipeContent = {
|
|
126
|
+
description,
|
|
127
|
+
equipment: equipmentContent,
|
|
128
|
+
ingredientSections: ingredientSectionsContent,
|
|
129
|
+
instructions: instructionsContent,
|
|
130
|
+
locale,
|
|
131
|
+
tips,
|
|
132
|
+
title,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const meta = {
|
|
136
|
+
...metaFields,
|
|
137
|
+
equipment: equipmentMeta,
|
|
138
|
+
ingredientSections: ingredientSectionsMeta,
|
|
139
|
+
instructions: instructionsMeta,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { meta, content };
|
|
143
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively removes undefined values from an object or array.
|
|
3
|
+
* This is necessary because Firestore rejects undefined values.
|
|
4
|
+
*
|
|
5
|
+
* @param obj - The object or array to clean
|
|
6
|
+
* @returns A new object/array with undefined values removed
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* stripUndefined({ a: 1, b: undefined, c: { d: undefined, e: 2 } })
|
|
10
|
+
* // Returns: { a: 1, c: { e: 2 } }
|
|
11
|
+
*/
|
|
12
|
+
export function stripUndefined<T>(obj: T): T {
|
|
13
|
+
if (obj === null || obj === undefined) {
|
|
14
|
+
return obj;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(obj)) {
|
|
18
|
+
return obj.map(item => stripUndefined(item)) as T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof obj === 'object') {
|
|
22
|
+
const cleaned: any = {};
|
|
23
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
24
|
+
if (value !== undefined) {
|
|
25
|
+
cleaned[key] = stripUndefined(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return cleaned as T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return obj;
|
|
32
|
+
}
|