@cravery/firebase 0.0.8 → 0.0.9

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 (174) hide show
  1. package/dist/converter/comment.d.ts +4 -0
  2. package/dist/converter/comment.d.ts.map +1 -0
  3. package/dist/converter/comment.js +36 -0
  4. package/dist/converter/comment.js.map +1 -0
  5. package/dist/converter/equipment.d.ts +5 -0
  6. package/dist/converter/equipment.d.ts.map +1 -0
  7. package/dist/converter/equipment.js +49 -0
  8. package/dist/converter/equipment.js.map +1 -0
  9. package/dist/converter/index.d.ts +10 -0
  10. package/dist/converter/index.d.ts.map +1 -0
  11. package/dist/converter/index.js +26 -0
  12. package/dist/converter/index.js.map +1 -0
  13. package/dist/converter/ingredient.d.ts +5 -0
  14. package/dist/converter/ingredient.d.ts.map +1 -0
  15. package/dist/converter/ingredient.js +49 -0
  16. package/dist/converter/ingredient.js.map +1 -0
  17. package/dist/converter/profile.d.ts +4 -0
  18. package/dist/converter/profile.d.ts.map +1 -0
  19. package/dist/converter/profile.js +40 -0
  20. package/dist/converter/profile.js.map +1 -0
  21. package/dist/converter/recipe.d.ts +5 -0
  22. package/dist/converter/recipe.d.ts.map +1 -0
  23. package/dist/converter/recipe.js +75 -0
  24. package/dist/converter/recipe.js.map +1 -0
  25. package/dist/converter/report.d.ts +4 -0
  26. package/dist/converter/report.d.ts.map +1 -0
  27. package/dist/converter/report.js +46 -0
  28. package/dist/converter/report.js.map +1 -0
  29. package/dist/converter/settings.d.ts +4 -0
  30. package/dist/converter/settings.d.ts.map +1 -0
  31. package/dist/converter/settings.js +44 -0
  32. package/dist/converter/settings.js.map +1 -0
  33. package/dist/converter/submission.d.ts +4 -0
  34. package/dist/converter/submission.d.ts.map +1 -0
  35. package/dist/converter/submission.js +41 -0
  36. package/dist/converter/submission.js.map +1 -0
  37. package/dist/converter/user.d.ts +4 -0
  38. package/dist/converter/user.d.ts.map +1 -0
  39. package/dist/converter/user.js +39 -0
  40. package/dist/converter/user.js.map +1 -0
  41. package/dist/equipment/equipment.repository.d.ts +6 -0
  42. package/dist/equipment/equipment.repository.d.ts.map +1 -0
  43. package/dist/equipment/equipment.repository.js +11 -0
  44. package/dist/equipment/equipment.repository.js.map +1 -0
  45. package/dist/equipment/index.d.ts +2 -0
  46. package/dist/equipment/index.d.ts.map +1 -0
  47. package/dist/equipment/index.js +18 -0
  48. package/dist/equipment/index.js.map +1 -0
  49. package/dist/iam/index.d.ts +2 -1
  50. package/dist/iam/index.d.ts.map +1 -1
  51. package/dist/iam/index.js +2 -1
  52. package/dist/iam/index.js.map +1 -1
  53. package/dist/iam/profile.converter.d.ts +4 -0
  54. package/dist/iam/profile.converter.d.ts.map +1 -0
  55. package/dist/iam/profile.converter.js +40 -0
  56. package/dist/iam/profile.converter.js.map +1 -0
  57. package/dist/iam/user.converter.d.ts +4 -0
  58. package/dist/iam/user.converter.d.ts.map +1 -0
  59. package/dist/iam/user.converter.js +39 -0
  60. package/dist/iam/user.converter.js.map +1 -0
  61. package/dist/index.d.ts +3 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +3 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/ingredient/index.d.ts +2 -0
  66. package/dist/ingredient/index.d.ts.map +1 -0
  67. package/dist/ingredient/index.js +18 -0
  68. package/dist/ingredient/index.js.map +1 -0
  69. package/dist/ingredient/ingredient.repository.d.ts +6 -0
  70. package/dist/ingredient/ingredient.repository.d.ts.map +1 -0
  71. package/dist/ingredient/ingredient.repository.js +11 -0
  72. package/dist/ingredient/ingredient.repository.js.map +1 -0
  73. package/dist/lib/asset.repository.d.ts +13 -0
  74. package/dist/lib/asset.repository.d.ts.map +1 -0
  75. package/dist/lib/asset.repository.js +65 -0
  76. package/dist/lib/asset.repository.js.map +1 -0
  77. package/dist/lib/index.d.ts +2 -0
  78. package/dist/lib/index.d.ts.map +1 -0
  79. package/dist/lib/index.js +18 -0
  80. package/dist/lib/index.js.map +1 -0
  81. package/dist/recipe/index.d.ts +2 -2
  82. package/dist/recipe/index.d.ts.map +1 -1
  83. package/dist/recipe/index.js +2 -2
  84. package/dist/recipe/index.js.map +1 -1
  85. package/dist/recipe/recipe.converter.d.ts +5 -0
  86. package/dist/recipe/recipe.converter.d.ts.map +1 -0
  87. package/dist/recipe/recipe.converter.js +86 -0
  88. package/dist/recipe/recipe.converter.js.map +1 -0
  89. package/dist/recipe/recipe.repository.d.ts +22 -0
  90. package/dist/recipe/recipe.repository.d.ts.map +1 -0
  91. package/dist/recipe/recipe.repository.js +210 -0
  92. package/dist/recipe/recipe.repository.js.map +1 -0
  93. package/dist/recipe/user_recipe_repository.d.ts +1 -1
  94. package/dist/recipe/user_recipe_repository.d.ts.map +1 -1
  95. package/dist/recipe/user_recipe_repository.js +7 -7
  96. package/dist/recipe/user_recipe_repository.js.map +1 -1
  97. package/dist/repository/base.d.ts +32 -0
  98. package/dist/repository/base.d.ts.map +1 -0
  99. package/dist/repository/base.js +167 -0
  100. package/dist/repository/base.js.map +1 -0
  101. package/dist/repository/equipment.d.ts +14 -0
  102. package/dist/repository/equipment.d.ts.map +1 -0
  103. package/dist/repository/equipment.js +33 -0
  104. package/dist/repository/equipment.js.map +1 -0
  105. package/dist/repository/index.d.ts +6 -0
  106. package/dist/repository/index.d.ts.map +1 -0
  107. package/dist/repository/index.js +22 -0
  108. package/dist/repository/index.js.map +1 -0
  109. package/dist/repository/ingredient.d.ts +14 -0
  110. package/dist/repository/ingredient.d.ts.map +1 -0
  111. package/dist/repository/ingredient.js +33 -0
  112. package/dist/repository/ingredient.js.map +1 -0
  113. package/dist/repository/recipe.d.ts +17 -0
  114. package/dist/repository/recipe.d.ts.map +1 -0
  115. package/dist/repository/recipe.js +92 -0
  116. package/dist/repository/recipe.js.map +1 -0
  117. package/dist/repository/recipe_stats.d.ts +19 -0
  118. package/dist/repository/recipe_stats.d.ts.map +1 -0
  119. package/dist/repository/recipe_stats.js +121 -0
  120. package/dist/repository/recipe_stats.js.map +1 -0
  121. package/dist/repository/user_recipe.d.ts +39 -0
  122. package/dist/repository/user_recipe.d.ts.map +1 -0
  123. package/dist/repository/user_recipe.js +160 -0
  124. package/dist/repository/user_recipe.js.map +1 -0
  125. package/dist/transform/index.d.ts +2 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +18 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/transform/recipe.d.ts +7 -0
  130. package/dist/transform/recipe.d.ts.map +1 -0
  131. package/dist/transform/recipe.js +118 -0
  132. package/dist/transform/recipe.js.map +1 -0
  133. package/dist/utils/cursor.d.ts +10 -0
  134. package/dist/utils/cursor.d.ts.map +1 -0
  135. package/dist/utils/cursor.js +31 -0
  136. package/dist/utils/cursor.js.map +1 -0
  137. package/dist/utils/index.d.ts +1 -0
  138. package/dist/utils/index.d.ts.map +1 -1
  139. package/dist/utils/index.js +1 -0
  140. package/dist/utils/index.js.map +1 -1
  141. package/dist/utils/timestamp.d.ts +2 -0
  142. package/dist/utils/timestamp.d.ts.map +1 -1
  143. package/dist/utils/timestamp.js +19 -0
  144. package/dist/utils/timestamp.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/converter/comment.ts +41 -0
  147. package/src/converter/equipment.ts +58 -0
  148. package/src/converter/index.ts +9 -0
  149. package/src/converter/ingredient.ts +58 -0
  150. package/src/converter/profile.ts +44 -0
  151. package/src/{recipe/converters.ts → converter/recipe.ts} +6 -20
  152. package/src/converter/report.ts +52 -0
  153. package/src/converter/settings.ts +49 -0
  154. package/src/converter/submission.ts +47 -0
  155. package/src/{iam/converters.ts → converter/user.ts} +10 -5
  156. package/src/index.ts +3 -3
  157. package/src/repository/base.ts +274 -0
  158. package/src/repository/equipment.ts +62 -0
  159. package/src/repository/index.ts +5 -0
  160. package/src/repository/ingredient.ts +62 -0
  161. package/src/repository/recipe.ts +147 -0
  162. package/src/{recipe/stats_repository.ts → repository/recipe_stats.ts} +22 -81
  163. package/src/repository/user_recipe.ts +225 -0
  164. package/src/transform/index.ts +1 -0
  165. package/src/utils/cursor.ts +34 -0
  166. package/src/utils/index.ts +1 -0
  167. package/src/utils/timestamp.ts +20 -0
  168. package/src/asset/index.ts +0 -1
  169. package/src/asset/repository.ts +0 -142
  170. package/src/iam/index.ts +0 -1
  171. package/src/recipe/index.ts +0 -5
  172. package/src/recipe/repository.ts +0 -284
  173. package/src/recipe/user_recipe_repository.ts +0 -240
  174. /package/src/{recipe/utils.ts → transform/recipe.ts} +0 -0
@@ -1,44 +1,35 @@
1
1
  import { Database } from "firebase-admin/database";
2
- import type { RecipeStats } from "@cravery/core";
3
-
4
- const STATS_PATH = "stats/recipes";
2
+ import { RTDB_PATHS, type RecipeStats } from "@cravery/core";
5
3
 
6
4
  export class RecipeStatsRepository {
7
5
  constructor(private db: Database) {}
8
6
 
7
+ private defaultStats = {
8
+ comments: 0,
9
+ likes: 0,
10
+ ratingCount: 0,
11
+ ratingSum: 0,
12
+ saves: 0,
13
+ updatedAt: Date.now(),
14
+ views: 0,
15
+ };
16
+
9
17
  private getRef(recipeId: string) {
10
- return this.db.ref(`${STATS_PATH}/${recipeId}`);
18
+ return this.db.ref(`${RTDB_PATHS.RecipeStats}/${recipeId}`);
11
19
  }
12
20
 
13
21
  async get(recipeId: string): Promise<RecipeStats> {
14
22
  const snapshot = await this.getRef(recipeId).get();
15
23
 
16
24
  if (!snapshot.exists()) {
17
- // Return default stats (no error)
18
- return {
19
- comments: 0,
20
- likes: 0,
21
- rating: 0,
22
- ratingCount: 0,
23
- saves: 0,
24
- updatedAt: Date.now(),
25
- views: 0,
26
- };
25
+ return this.defaultStats;
27
26
  }
28
27
 
29
28
  return snapshot.val() as RecipeStats;
30
29
  }
31
30
 
32
31
  async initialize(recipeId: string): Promise<RecipeStats> {
33
- const initialStats: RecipeStats = {
34
- comments: 0,
35
- likes: 0,
36
- rating: 0,
37
- ratingCount: 0,
38
- saves: 0,
39
- updatedAt: Date.now(),
40
- views: 0,
41
- };
32
+ const initialStats: RecipeStats = this.defaultStats;
42
33
 
43
34
  const ref = this.getRef(recipeId);
44
35
  const snapshot = await ref.get();
@@ -54,12 +45,7 @@ export class RecipeStatsRepository {
54
45
  await this.getRef(recipeId).transaction((current) => {
55
46
  if (!current) {
56
47
  return {
57
- comments: 0,
58
- likes: 0,
59
- rating: 0,
60
- ratingCount: 0,
61
- saves: 0,
62
- updatedAt: Date.now(),
48
+ ...this.defaultStats,
63
49
  views: 1,
64
50
  };
65
51
  }
@@ -73,13 +59,8 @@ export class RecipeStatsRepository {
73
59
  await this.getRef(recipeId).transaction((current) => {
74
60
  if (!current) {
75
61
  return {
76
- comments: 0,
62
+ ...this.defaultStats,
77
63
  likes: 1,
78
- rating: 0,
79
- ratingCount: 0,
80
- saves: 0,
81
- updatedAt: Date.now(),
82
- views: 0,
83
64
  };
84
65
  }
85
66
  return {
@@ -95,15 +76,7 @@ export class RecipeStatsRepository {
95
76
  async decrementLikes(recipeId: string): Promise<RecipeStats> {
96
77
  await this.getRef(recipeId).transaction((current) => {
97
78
  if (!current) {
98
- return {
99
- comments: 0,
100
- likes: 0,
101
- rating: 0,
102
- ratingCount: 0,
103
- saves: 0,
104
- updatedAt: Date.now(),
105
- views: 0,
106
- };
79
+ return this.defaultStats;
107
80
  }
108
81
  return {
109
82
  ...current,
@@ -119,13 +92,8 @@ export class RecipeStatsRepository {
119
92
  await this.getRef(recipeId).transaction((current) => {
120
93
  if (!current) {
121
94
  return {
122
- comments: 0,
123
- likes: 0,
124
- rating: 0,
125
- ratingCount: 0,
95
+ ...this.defaultStats,
126
96
  saves: 1,
127
- updatedAt: Date.now(),
128
- views: 0,
129
97
  };
130
98
  }
131
99
  return {
@@ -141,15 +109,7 @@ export class RecipeStatsRepository {
141
109
  async decrementSaves(recipeId: string): Promise<RecipeStats> {
142
110
  await this.getRef(recipeId).transaction((current) => {
143
111
  if (!current) {
144
- return {
145
- comments: 0,
146
- likes: 0,
147
- rating: 0,
148
- ratingCount: 0,
149
- saves: 0,
150
- updatedAt: Date.now(),
151
- views: 0,
152
- };
112
+ return this.defaultStats;
153
113
  }
154
114
  return {
155
115
  ...current,
@@ -169,13 +129,9 @@ export class RecipeStatsRepository {
169
129
  await this.getRef(recipeId).transaction((current) => {
170
130
  if (!current) {
171
131
  return {
172
- comments: 0,
173
- likes: 0,
132
+ ...this.defaultStats,
174
133
  rating,
175
134
  ratingCount: 1,
176
- saves: 0,
177
- updatedAt: Date.now(),
178
- views: 0,
179
135
  };
180
136
  }
181
137
 
@@ -183,11 +139,9 @@ export class RecipeStatsRepository {
183
139
  let newRating: number;
184
140
 
185
141
  if (previousRating !== undefined) {
186
- // Updating existing rating
187
142
  const totalRating = current.rating * current.ratingCount;
188
143
  newRating = (totalRating - previousRating + rating) / newCount;
189
144
  } else {
190
- // New rating
191
145
  newCount = current.ratingCount + 1;
192
146
  newRating = (current.rating * current.ratingCount + rating) / newCount;
193
147
  }
@@ -207,13 +161,8 @@ export class RecipeStatsRepository {
207
161
  await this.getRef(recipeId).transaction((current) => {
208
162
  if (!current) {
209
163
  return {
164
+ ...this.defaultStats,
210
165
  comments: 1,
211
- likes: 0,
212
- rating: 0,
213
- ratingCount: 0,
214
- saves: 0,
215
- updatedAt: Date.now(),
216
- views: 0,
217
166
  };
218
167
  }
219
168
  return {
@@ -229,15 +178,7 @@ export class RecipeStatsRepository {
229
178
  async decrementComments(recipeId: string): Promise<RecipeStats> {
230
179
  await this.getRef(recipeId).transaction((current) => {
231
180
  if (!current) {
232
- return {
233
- comments: 0,
234
- likes: 0,
235
- rating: 0,
236
- ratingCount: 0,
237
- saves: 0,
238
- updatedAt: Date.now(),
239
- views: 0,
240
- };
181
+ return this.defaultStats;
241
182
  }
242
183
  return {
243
184
  ...current,
@@ -0,0 +1,225 @@
1
+ import { Firestore, Timestamp } from "firebase-admin/firestore";
2
+ import { Database, ServerValue } from "firebase-admin/database";
3
+ import {
4
+ type Recipe,
5
+ type RecipeMeta,
6
+ type RecipeContent,
7
+ type Locale,
8
+ COLLECTIONS,
9
+ RTDB_PATHS,
10
+ } from "@cravery/core";
11
+ import {
12
+ recipeMetaConverter,
13
+ recipeContentConverter,
14
+ } from "../converter";
15
+ import { mergeRecipe, splitRecipe } from "../transform";
16
+ import { RecipeRepository } from "./recipe";
17
+
18
+ export class UserRecipeRepository {
19
+ constructor(
20
+ private db: Firestore,
21
+ private rtdb: Database,
22
+ ) {}
23
+
24
+ private userCollection(userId: string) {
25
+ return this.db.collection(COLLECTIONS.Users).doc(userId);
26
+ }
27
+
28
+ private savedCollection(userId: string) {
29
+ return this.userCollection(userId).collection(COLLECTIONS.Saved);
30
+ }
31
+
32
+ private likesCollection(userId: string) {
33
+ return this.userCollection(userId).collection(COLLECTIONS.Likes);
34
+ }
35
+
36
+ private ratingsCollection(userId: string) {
37
+ return this.userCollection(userId).collection(COLLECTIONS.Ratings);
38
+ }
39
+
40
+ private recipesCollection(userId: string) {
41
+ return this.userCollection(userId).collection(COLLECTIONS.Recipes);
42
+ }
43
+
44
+ private recipeStatsRef(recipeId: string) {
45
+ return this.rtdb.ref(`${RTDB_PATHS.RecipeStats}/${recipeId}`);
46
+ }
47
+
48
+ async toggleSave(userId: string, recipeId: string): Promise<{ saved: boolean }> {
49
+ const saveRef = this.savedCollection(userId).doc(recipeId);
50
+ const doc = await saveRef.get();
51
+
52
+ if (doc.exists) {
53
+ await saveRef.delete();
54
+ await this.recipeStatsRef(recipeId).child("saves").set(ServerValue.increment(-1));
55
+ return { saved: false };
56
+ }
57
+
58
+ await saveRef.set({
59
+ recipeId,
60
+ savedAt: Timestamp.now(),
61
+ });
62
+ await this.recipeStatsRef(recipeId).child("saves").set(ServerValue.increment(1));
63
+ return { saved: true };
64
+ }
65
+
66
+ async toggleLike(userId: string, recipeId: string): Promise<{ liked: boolean }> {
67
+ const likeRef = this.likesCollection(userId).doc(recipeId);
68
+ const doc = await likeRef.get();
69
+
70
+ if (doc.exists) {
71
+ await likeRef.delete();
72
+ await this.recipeStatsRef(recipeId).child("likes").set(ServerValue.increment(-1));
73
+ return { liked: false };
74
+ }
75
+
76
+ await likeRef.set({
77
+ recipeId,
78
+ likedAt: Timestamp.now(),
79
+ });
80
+ await this.recipeStatsRef(recipeId).child("likes").set(ServerValue.increment(1));
81
+ return { liked: true };
82
+ }
83
+
84
+ async rateRecipe(
85
+ userId: string,
86
+ recipeId: string,
87
+ rating: number,
88
+ ): Promise<{ previousRating?: number }> {
89
+ if (rating < 1 || rating > 5) {
90
+ throw new Error("Rating must be between 1 and 5");
91
+ }
92
+
93
+ const ratingRef = this.ratingsCollection(userId).doc(recipeId);
94
+ const doc = await ratingRef.get();
95
+ const previousRating = doc.exists ? doc.data()?.rating : undefined;
96
+
97
+ await ratingRef.set({
98
+ recipeId,
99
+ rating,
100
+ ratedAt: Timestamp.now(),
101
+ });
102
+
103
+ const statsRef = this.recipeStatsRef(recipeId);
104
+ if (previousRating === undefined) {
105
+ await statsRef.update({
106
+ ratingCount: ServerValue.increment(1),
107
+ ratingSum: ServerValue.increment(rating),
108
+ });
109
+ } else {
110
+ await statsRef.child("ratingSum").set(ServerValue.increment(rating - previousRating));
111
+ }
112
+
113
+ return { previousRating };
114
+ }
115
+
116
+ async cloneRecipe(
117
+ userId: string,
118
+ recipeId: string,
119
+ locale: Locale,
120
+ sourceRepository: RecipeRepository,
121
+ ): Promise<Recipe> {
122
+ const recipeRef = this.recipesCollection(userId).doc(recipeId);
123
+ const existingDoc = await recipeRef.get();
124
+
125
+ if (existingDoc.exists) {
126
+ throw new Error(`Recipe ${recipeId} already cloned for user ${userId}`);
127
+ }
128
+
129
+ const sourceRecipe = await sourceRepository.getById(recipeId, locale);
130
+ const { meta, content } = splitRecipe(sourceRecipe);
131
+
132
+ await recipeRef.withConverter(recipeMetaConverter).set(meta as RecipeMeta);
133
+ await recipeRef
134
+ .collection(COLLECTIONS.Content)
135
+ .doc(locale)
136
+ .withConverter(recipeContentConverter)
137
+ .set(content);
138
+
139
+ return sourceRecipe;
140
+ }
141
+
142
+ async getUserRecipe(userId: string, recipeId: string, locale: Locale): Promise<Recipe> {
143
+ const recipeRef = this.recipesCollection(userId).doc(recipeId);
144
+
145
+ const [metaDoc, contentDoc] = await Promise.all([
146
+ recipeRef.withConverter(recipeMetaConverter).get(),
147
+ recipeRef
148
+ .collection(COLLECTIONS.Content)
149
+ .doc(locale)
150
+ .withConverter(recipeContentConverter)
151
+ .get(),
152
+ ]);
153
+
154
+ if (!metaDoc.exists) {
155
+ throw new Error(`Recipe ${recipeId} not found in user ${userId}'s collection`);
156
+ }
157
+
158
+ if (!contentDoc.exists) {
159
+ throw new Error(`Recipe ${recipeId} has no content for locale ${locale}`);
160
+ }
161
+
162
+ return mergeRecipe(metaDoc.data()!, contentDoc.data()!);
163
+ }
164
+
165
+ async updateUserRecipe(
166
+ userId: string,
167
+ recipeId: string,
168
+ locale: Locale,
169
+ updates: {
170
+ meta?: Partial<Omit<RecipeMeta, "id" | "createdAt" | "createdBy">>;
171
+ content?: Partial<RecipeContent>;
172
+ },
173
+ ): Promise<void> {
174
+ const recipeRef = this.recipesCollection(userId).doc(recipeId);
175
+ const metaDoc = await recipeRef.get();
176
+
177
+ if (!metaDoc.exists) {
178
+ throw new Error(`Recipe ${recipeId} not found in user ${userId}'s collection`);
179
+ }
180
+
181
+ const batch = this.db.batch();
182
+
183
+ if (updates.meta) {
184
+ batch.update(recipeRef, {
185
+ ...updates.meta,
186
+ updatedAt: Timestamp.now(),
187
+ });
188
+ }
189
+
190
+ if (updates.content) {
191
+ const contentRef = recipeRef.collection(COLLECTIONS.Content).doc(locale);
192
+ batch.set(contentRef, { ...updates.content, locale }, { merge: true });
193
+ }
194
+
195
+ await batch.commit();
196
+ }
197
+
198
+ async isSaved(userId: string, recipeId: string): Promise<boolean> {
199
+ const doc = await this.savedCollection(userId).doc(recipeId).get();
200
+ return doc.exists;
201
+ }
202
+
203
+ async isLiked(userId: string, recipeId: string): Promise<boolean> {
204
+ const doc = await this.likesCollection(userId).doc(recipeId).get();
205
+ return doc.exists;
206
+ }
207
+
208
+ async getUserRating(userId: string, recipeId: string): Promise<number | null> {
209
+ const doc = await this.ratingsCollection(userId).doc(recipeId).get();
210
+ return doc.exists ? (doc.data()?.rating ?? null) : null;
211
+ }
212
+
213
+ async getRecipeInteractions(
214
+ userId: string,
215
+ recipeId: string,
216
+ ): Promise<{ saved: boolean; liked: boolean; rating: number | null }> {
217
+ const [saved, liked, rating] = await Promise.all([
218
+ this.isSaved(userId, recipeId),
219
+ this.isLiked(userId, recipeId),
220
+ this.getUserRating(userId, recipeId),
221
+ ]);
222
+
223
+ return { saved, liked, rating };
224
+ }
225
+ }
@@ -0,0 +1 @@
1
+ export * from "./recipe";
@@ -0,0 +1,34 @@
1
+ import { Timestamp } from "firebase-admin/firestore";
2
+
3
+ interface CursorData {
4
+ sortValue: number;
5
+ id: string;
6
+ }
7
+
8
+ export function encodeCursor(sortValue: Timestamp, id: string): string {
9
+ const data: CursorData = {
10
+ sortValue: sortValue.toMillis(),
11
+ id,
12
+ };
13
+ return Buffer.from(JSON.stringify(data)).toString("base64url");
14
+ }
15
+
16
+ export function decodeCursor(cursor: string): CursorData {
17
+ try {
18
+ const json = Buffer.from(cursor, "base64url").toString("utf-8");
19
+ const data = JSON.parse(json) as CursorData;
20
+
21
+ if (typeof data.sortValue !== "number" || typeof data.id !== "string") {
22
+ throw new Error("Invalid cursor structure");
23
+ }
24
+
25
+ return data;
26
+ } catch {
27
+ throw new Error("Invalid cursor");
28
+ }
29
+ }
30
+
31
+ export function cursorToFirestoreValues(cursor: string): [Timestamp, string] {
32
+ const { sortValue, id } = decodeCursor(cursor);
33
+ return [Timestamp.fromMillis(sortValue), id];
34
+ }
@@ -1,2 +1,3 @@
1
+ export * from "./cursor";
1
2
  export * from "./strip-undefined";
2
3
  export * from "./timestamp";
@@ -19,3 +19,23 @@ export function toOptionalTimestamp(
19
19
  ): Timestamp | undefined {
20
20
  return timestamp ? toTimestamp(timestamp) : undefined;
21
21
  }
22
+
23
+ function isFirestoreTimestamp(ts: unknown): ts is FirestoreTimestamp {
24
+ return typeof ts === "object" && ts !== null && "_seconds" in ts;
25
+ }
26
+
27
+ export function toFirestoreTimestamp(
28
+ ts: Timestamp | FirestoreTimestamp | null | undefined,
29
+ ): FirestoreTimestamp | null | undefined {
30
+ if (!ts) return ts;
31
+ if (isFirestoreTimestamp(ts)) return ts;
32
+ return fromTimestamp(ts);
33
+ }
34
+
35
+ export function toOptionalFirestoreTimestamp(
36
+ ts: Timestamp | FirestoreTimestamp | undefined,
37
+ ): FirestoreTimestamp | undefined {
38
+ if (!ts) return undefined;
39
+ if (isFirestoreTimestamp(ts)) return ts;
40
+ return fromTimestamp(ts);
41
+ }
@@ -1 +0,0 @@
1
- export * from "./repository";
@@ -1,142 +0,0 @@
1
- import { Firestore, Timestamp } from "firebase-admin/firestore";
2
- import { Asset } from "@cravery/core";
3
-
4
- export class AssetRepository {
5
- constructor(
6
- private db: Firestore,
7
- private collectionName: string,
8
- ) {}
9
-
10
- private get collection() {
11
- return this.db.collection(this.collectionName);
12
- }
13
-
14
- async findById(id: string): Promise<Asset | null> {
15
- const doc = await this.collection.doc(id).get();
16
- if (!doc.exists) return null;
17
- return doc.data() as Asset;
18
- }
19
-
20
- async exists(id: string): Promise<boolean> {
21
- const doc = await this.collection.doc(id).get();
22
- return doc.exists;
23
- }
24
-
25
- async slugExists(slug: string): Promise<boolean> {
26
- return this.exists(slug);
27
- }
28
-
29
- async update(slug: string, data: Partial<Asset>): Promise<void> {
30
- await this.collection.doc(slug).update({
31
- ...data,
32
- updatedAt: Timestamp.now(),
33
- });
34
- }
35
-
36
- async getLocale(
37
- slug: string,
38
- locale: string,
39
- ): Promise<{ locale: string; name: string; notes?: string } | null> {
40
- const docRef = this.collection.doc(slug).collection("locales").doc(locale);
41
- const doc = await docRef.get();
42
- if (!doc.exists) return null;
43
- return { locale, ...doc.data() } as {
44
- locale: string;
45
- name: string;
46
- notes?: string;
47
- };
48
- }
49
-
50
- async setLocale(
51
- slug: string,
52
- locale: string,
53
- data: { name: string; notes?: string },
54
- ): Promise<void> {
55
- const docRef = this.collection.doc(slug).collection("locales").doc(locale);
56
- await docRef.set({ locale, ...data });
57
- }
58
-
59
- async getAllLocales(
60
- slug: string,
61
- ): Promise<{ locale: string; name: string; notes?: string }[]> {
62
- const snapshot = await this.collection
63
- .doc(slug)
64
- .collection("locales")
65
- .get();
66
- return snapshot.docs.map(
67
- (doc) =>
68
- ({
69
- locale: doc.id,
70
- ...doc.data(),
71
- }) as { locale: string; name: string; notes?: string },
72
- );
73
- }
74
-
75
- async createWithLocale(
76
- slug: string,
77
- imageUrl: string,
78
- name: string,
79
- locale: string,
80
- localeData: { name: string; notes?: string },
81
- ): Promise<Asset> {
82
- const now = Timestamp.now();
83
- const batch = this.db.batch();
84
-
85
- const docRef = this.collection.doc(slug);
86
- const assetData: Asset = {
87
- id: slug,
88
- imageUrl,
89
- name,
90
- createdAt: now,
91
- updatedAt: now,
92
- };
93
- batch.set(docRef, assetData);
94
-
95
- const localeRef = docRef.collection("locales").doc(locale);
96
- batch.set(localeRef, { locale, ...localeData });
97
-
98
- await batch.commit();
99
- return assetData;
100
- }
101
-
102
- async getWithPagination(
103
- page: number = 1,
104
- limit: number = 50,
105
- searchQuery?: string,
106
- ): Promise<{ assets: Asset[]; total: number }> {
107
- // Build base query ordered by updatedAt descending
108
- const query = this.collection.orderBy("updatedAt", "desc");
109
-
110
- // Get all documents for accurate count and filtering
111
- // Note: For very large datasets (10k+ docs), consider maintaining a separate counter
112
- const allSnapshot = await query.get();
113
- let allAssets = allSnapshot.docs.map((doc) => doc.data() as Asset);
114
-
115
- // Filter by search query if provided
116
- if (searchQuery && searchQuery.trim()) {
117
- const q = searchQuery.toLowerCase();
118
- allAssets = allAssets.filter((asset) =>
119
- asset.id.toLowerCase().includes(q),
120
- );
121
- }
122
-
123
- // Calculate pagination
124
- const total = allAssets.length;
125
- const offset = (page - 1) * limit;
126
- const paginatedAssets = allAssets.slice(offset, offset + limit);
127
-
128
- return { assets: paginatedAssets, total };
129
- }
130
- }
131
-
132
- export class IngredientRepository extends AssetRepository {
133
- constructor(db: Firestore) {
134
- super(db, "ingredients");
135
- }
136
- }
137
-
138
- export class EquipmentRepository extends AssetRepository {
139
- constructor(db: Firestore) {
140
- super(db, "equipment");
141
- }
142
- }
package/src/iam/index.ts DELETED
@@ -1 +0,0 @@
1
- export * from "./converters";
@@ -1,5 +0,0 @@
1
- export * from "./converters";
2
- export * from "./repository";
3
- export * from "./stats_repository";
4
- export * from "./user_recipe_repository";
5
- export * from "./utils";