@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.
Files changed (51) hide show
  1. package/dist/iam/converters.d.ts +4 -0
  2. package/dist/iam/converters.d.ts.map +1 -0
  3. package/dist/iam/converters.js +39 -0
  4. package/dist/iam/converters.js.map +1 -0
  5. package/dist/iam/index.d.ts +2 -0
  6. package/dist/iam/index.d.ts.map +1 -0
  7. package/dist/iam/index.js +18 -0
  8. package/dist/iam/index.js.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +20 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/recipe/converters.d.ts +5 -0
  14. package/dist/recipe/converters.d.ts.map +1 -0
  15. package/dist/recipe/converters.js +90 -0
  16. package/dist/recipe/converters.js.map +1 -0
  17. package/dist/recipe/index.d.ts +4 -0
  18. package/dist/recipe/index.d.ts.map +1 -0
  19. package/dist/recipe/index.js +20 -0
  20. package/dist/recipe/index.js.map +1 -0
  21. package/dist/recipe/repository.d.ts +21 -0
  22. package/dist/recipe/repository.d.ts.map +1 -0
  23. package/dist/recipe/repository.js +157 -0
  24. package/dist/recipe/repository.js.map +1 -0
  25. package/dist/recipe/utils.d.ts +7 -0
  26. package/dist/recipe/utils.d.ts.map +1 -0
  27. package/dist/recipe/utils.js +118 -0
  28. package/dist/recipe/utils.js.map +1 -0
  29. package/dist/utils/index.d.ts +3 -0
  30. package/dist/utils/index.d.ts.map +1 -0
  31. package/dist/utils/index.js +19 -0
  32. package/dist/utils/index.js.map +1 -0
  33. package/dist/utils/strip-undefined.d.ts +13 -0
  34. package/dist/utils/strip-undefined.d.ts.map +1 -0
  35. package/dist/utils/strip-undefined.js +33 -0
  36. package/dist/utils/strip-undefined.js.map +1 -0
  37. package/dist/utils/timestamp.d.ts +6 -0
  38. package/dist/utils/timestamp.d.ts.map +1 -0
  39. package/dist/utils/timestamp.js +19 -0
  40. package/dist/utils/timestamp.js.map +1 -0
  41. package/package.json +58 -0
  42. package/src/iam/converters.ts +38 -0
  43. package/src/iam/index.ts +1 -0
  44. package/src/index.ts +3 -0
  45. package/src/recipe/converters.ts +93 -0
  46. package/src/recipe/index.ts +3 -0
  47. package/src/recipe/repository.ts +220 -0
  48. package/src/recipe/utils.ts +143 -0
  49. package/src/utils/index.ts +2 -0
  50. package/src/utils/strip-undefined.ts +32 -0
  51. 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
+ };
@@ -0,0 +1 @@
1
+ export * from "./converters";
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./iam";
2
+ export * from "./recipe";
3
+ export * from "./utils";
@@ -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,3 @@
1
+ export * from "./converters";
2
+ export * from "./repository";
3
+ export * from "./utils";
@@ -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,2 @@
1
+ export * from "./strip-undefined";
2
+ export * from "./timestamp";
@@ -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
+ }