@cravery/firebase 0.0.1 → 0.0.2

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 (36) hide show
  1. package/dist/recipe/converters.d.ts.map +1 -1
  2. package/dist/recipe/converters.js +2 -2
  3. package/dist/recipe/converters.js.map +1 -1
  4. package/dist/recipe/index.d.ts +2 -0
  5. package/dist/recipe/index.d.ts.map +1 -1
  6. package/dist/recipe/index.js +2 -0
  7. package/dist/recipe/index.js.map +1 -1
  8. package/dist/recipe/repository.d.ts +5 -0
  9. package/dist/recipe/repository.d.ts.map +1 -1
  10. package/dist/recipe/repository.js +46 -3
  11. package/dist/recipe/repository.js.map +1 -1
  12. package/dist/recipe/stats_repository.d.ts +18 -0
  13. package/dist/recipe/stats_repository.d.ts.map +1 -0
  14. package/dist/recipe/stats_repository.js +195 -0
  15. package/dist/recipe/stats_repository.js.map +1 -0
  16. package/dist/recipe/user_recipe_repository.d.ts +26 -0
  17. package/dist/recipe/user_recipe_repository.d.ts.map +1 -0
  18. package/dist/recipe/user_recipe_repository.js +180 -0
  19. package/dist/recipe/user_recipe_repository.js.map +1 -0
  20. package/dist/utils/strip-undefined.js +2 -2
  21. package/dist/utils/strip-undefined.js.map +1 -1
  22. package/dist/utils/timestamp.d.ts +1 -1
  23. package/dist/utils/timestamp.d.ts.map +1 -1
  24. package/package.json +60 -58
  25. package/src/iam/converters.ts +38 -38
  26. package/src/iam/index.ts +1 -1
  27. package/src/index.ts +3 -3
  28. package/src/recipe/converters.ts +98 -93
  29. package/src/recipe/index.ts +5 -3
  30. package/src/recipe/repository.ts +289 -220
  31. package/src/recipe/stats_repository.ts +251 -0
  32. package/src/recipe/user_recipe_repository.ts +242 -0
  33. package/src/recipe/utils.ts +143 -143
  34. package/src/utils/index.ts +2 -2
  35. package/src/utils/strip-undefined.ts +32 -32
  36. package/src/utils/timestamp.ts +21 -21
@@ -1,220 +1,289 @@
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
- }
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(
120
+ (doc) => !doc.data().deletedAt,
121
+ );
122
+
123
+ const recipes = await Promise.all(
124
+ nonDeletedDocs.map(async (metaDoc) => {
125
+ const meta = metaDoc.data();
126
+ const contentDoc = await this.contentCollection(metaDoc.id)
127
+ .doc(locale)
128
+ .get();
129
+
130
+ let content: RecipeContent;
131
+ if (contentDoc.exists) {
132
+ content = contentDoc.data()!;
133
+ } else {
134
+ const originalContentDoc = await this.contentCollection(metaDoc.id)
135
+ .doc(meta.originalLocale)
136
+ .get();
137
+ content = originalContentDoc.data()!;
138
+ }
139
+
140
+ return mergeRecipe(meta, content);
141
+ }),
142
+ );
143
+
144
+ return recipes;
145
+ }
146
+
147
+ async getByMultipleStatuses(
148
+ statuses: RecipeStatus[],
149
+ locale: Locale,
150
+ page = 1,
151
+ limit = 20,
152
+ ): Promise<{ recipes: Recipe[]; total: number }> {
153
+ // Build base query - no deletedAt filter on query level
154
+ const baseQuery = this.metaCollection.where("status", "in", statuses);
155
+
156
+ // Get all documents for accurate count
157
+ const allDocsSnapshot = await baseQuery.get();
158
+
159
+ // Client-side filtering for deletedAt
160
+ const nonDeletedDocs = allDocsSnapshot.docs.filter(
161
+ (doc) => !doc.data().deletedAt,
162
+ );
163
+
164
+ // Sort by createdAt desc
165
+ const sortedDocs = nonDeletedDocs.sort((a, b) => {
166
+ const aTime = a.data().createdAt;
167
+ const bTime = b.data().createdAt;
168
+ return bTime.seconds - aTime.seconds;
169
+ });
170
+
171
+ const total = sortedDocs.length;
172
+ const offset = (page - 1) * limit;
173
+ const paginatedDocs = sortedDocs.slice(offset, offset + limit);
174
+
175
+ const recipes = await Promise.all(
176
+ paginatedDocs.map(async (metaDoc) => {
177
+ const meta = metaDoc.data();
178
+ const contentDoc = await this.contentCollection(metaDoc.id)
179
+ .doc(locale)
180
+ .get();
181
+
182
+ let content: RecipeContent;
183
+ if (contentDoc.exists) {
184
+ content = contentDoc.data()!;
185
+ } else {
186
+ const originalContentDoc = await this.contentCollection(metaDoc.id)
187
+ .doc(meta.originalLocale)
188
+ .get();
189
+ content = originalContentDoc.data()!;
190
+ }
191
+
192
+ return mergeRecipe(meta, content);
193
+ }),
194
+ );
195
+
196
+ return { recipes, total };
197
+ }
198
+
199
+ async findBySourceUrl(sourceUrl: string): Promise<Recipe | null> {
200
+ const metaSnapshot = await this.metaCollection
201
+ .where("sourceUrl", "==", sourceUrl)
202
+ .limit(1)
203
+ .get();
204
+
205
+ if (metaSnapshot.empty) {
206
+ return null;
207
+ }
208
+
209
+ const metaDoc = metaSnapshot.docs[0];
210
+ const meta = metaDoc.data();
211
+
212
+ const contentDoc = await this.contentCollection(metaDoc.id)
213
+ .doc(meta.originalLocale)
214
+ .get();
215
+
216
+ if (!contentDoc.exists) {
217
+ throw new Error(`Recipe ${metaDoc.id} has no content`);
218
+ }
219
+
220
+ const content = contentDoc.data()!;
221
+ return mergeRecipe(meta, content);
222
+ }
223
+
224
+ async getMeta(recipeId: string): Promise<RecipeMeta> {
225
+ const metaDoc = await this.metaCollection.doc(recipeId).get();
226
+
227
+ if (!metaDoc.exists) {
228
+ throw new Error(`Recipe ${recipeId} not found`);
229
+ }
230
+
231
+ return metaDoc.data()!;
232
+ }
233
+
234
+ async getContent(recipeId: string, locale: Locale): Promise<RecipeContent> {
235
+ const contentDoc = await this.contentCollection(recipeId).doc(locale).get();
236
+
237
+ if (!contentDoc.exists) {
238
+ throw new Error(
239
+ `Recipe ${recipeId} has no content for locale ${locale}`,
240
+ );
241
+ }
242
+
243
+ return contentDoc.data()!;
244
+ }
245
+
246
+ async createContent(
247
+ recipeId: string,
248
+ content: RecipeContent,
249
+ ): Promise<void> {
250
+ // Verify recipe exists
251
+ const metaDoc = await this.metaCollection.doc(recipeId).get();
252
+ if (!metaDoc.exists) {
253
+ throw new Error(`Recipe ${recipeId} not found`);
254
+ }
255
+
256
+ // Check if locale already exists
257
+ const existingContent = await this.contentCollection(recipeId)
258
+ .doc(content.locale)
259
+ .get();
260
+
261
+ if (existingContent.exists) {
262
+ throw new Error(
263
+ `Content for recipe ${recipeId} in locale ${content.locale} already exists. Use updateContent() instead.`,
264
+ );
265
+ }
266
+
267
+ await this.contentCollection(recipeId).doc(content.locale).set(content);
268
+ }
269
+
270
+ async getAllContent(recipeId: string): Promise<RecipeContent[]> {
271
+ const metaDoc = await this.metaCollection.doc(recipeId).get();
272
+ if (!metaDoc.exists) {
273
+ throw new Error(`Recipe ${recipeId} not found`);
274
+ }
275
+
276
+ const snapshot = await this.contentCollection(recipeId).get();
277
+
278
+ if (snapshot.empty) {
279
+ throw new Error(`Recipe ${recipeId} has no content`);
280
+ }
281
+
282
+ return snapshot.docs.map((doc) => doc.data());
283
+ }
284
+
285
+ async hasLocale(recipeId: string, locale: Locale): Promise<boolean> {
286
+ const contentDoc = await this.contentCollection(recipeId).doc(locale).get();
287
+ return contentDoc.exists;
288
+ }
289
+ }