@cravery/firebase 0.0.1 → 0.0.3

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 +284 -220
  31. package/src/recipe/stats_repository.ts +251 -0
  32. package/src/recipe/user_recipe_repository.ts +240 -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
@@ -0,0 +1,251 @@
1
+ import { Database } from "firebase-admin/database";
2
+ import type { RecipeStats } from "@cravery/core";
3
+
4
+ const STATS_PATH = "stats/recipes";
5
+
6
+ export class RecipeStatsRepository {
7
+ constructor(private db: Database) {}
8
+
9
+ private getRef(recipeId: string) {
10
+ return this.db.ref(`${STATS_PATH}/${recipeId}`);
11
+ }
12
+
13
+ async get(recipeId: string): Promise<RecipeStats> {
14
+ const snapshot = await this.getRef(recipeId).get();
15
+
16
+ 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
+ };
27
+ }
28
+
29
+ return snapshot.val() as RecipeStats;
30
+ }
31
+
32
+ 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
+ };
42
+
43
+ const ref = this.getRef(recipeId);
44
+ const snapshot = await ref.get();
45
+
46
+ if (!snapshot.exists()) {
47
+ await ref.set(initialStats);
48
+ }
49
+
50
+ return initialStats;
51
+ }
52
+
53
+ async incrementViews(recipeId: string): Promise<RecipeStats> {
54
+ await this.getRef(recipeId).transaction((current) => {
55
+ if (!current) {
56
+ return {
57
+ comments: 0,
58
+ likes: 0,
59
+ rating: 0,
60
+ ratingCount: 0,
61
+ saves: 0,
62
+ updatedAt: Date.now(),
63
+ views: 1,
64
+ };
65
+ }
66
+ return { ...current, views: current.views + 1, updatedAt: Date.now() };
67
+ });
68
+
69
+ return this.get(recipeId);
70
+ }
71
+
72
+ async incrementLikes(recipeId: string): Promise<RecipeStats> {
73
+ await this.getRef(recipeId).transaction((current) => {
74
+ if (!current) {
75
+ return {
76
+ comments: 0,
77
+ likes: 1,
78
+ rating: 0,
79
+ ratingCount: 0,
80
+ saves: 0,
81
+ updatedAt: Date.now(),
82
+ views: 0,
83
+ };
84
+ }
85
+ return {
86
+ ...current,
87
+ likes: Math.max(0, current.likes + 1),
88
+ updatedAt: Date.now(),
89
+ };
90
+ });
91
+
92
+ return this.get(recipeId);
93
+ }
94
+
95
+ async decrementLikes(recipeId: string): Promise<RecipeStats> {
96
+ await this.getRef(recipeId).transaction((current) => {
97
+ 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
+ };
107
+ }
108
+ return {
109
+ ...current,
110
+ likes: Math.max(0, current.likes - 1),
111
+ updatedAt: Date.now(),
112
+ };
113
+ });
114
+
115
+ return this.get(recipeId);
116
+ }
117
+
118
+ async incrementSaves(recipeId: string): Promise<RecipeStats> {
119
+ await this.getRef(recipeId).transaction((current) => {
120
+ if (!current) {
121
+ return {
122
+ comments: 0,
123
+ likes: 0,
124
+ rating: 0,
125
+ ratingCount: 0,
126
+ saves: 1,
127
+ updatedAt: Date.now(),
128
+ views: 0,
129
+ };
130
+ }
131
+ return {
132
+ ...current,
133
+ saves: Math.max(0, current.saves + 1),
134
+ updatedAt: Date.now(),
135
+ };
136
+ });
137
+
138
+ return this.get(recipeId);
139
+ }
140
+
141
+ async decrementSaves(recipeId: string): Promise<RecipeStats> {
142
+ await this.getRef(recipeId).transaction((current) => {
143
+ 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
+ };
153
+ }
154
+ return {
155
+ ...current,
156
+ saves: Math.max(0, current.saves - 1),
157
+ updatedAt: Date.now(),
158
+ };
159
+ });
160
+
161
+ return this.get(recipeId);
162
+ }
163
+
164
+ async addRating(
165
+ recipeId: string,
166
+ rating: number,
167
+ previousRating?: number,
168
+ ): Promise<RecipeStats> {
169
+ await this.getRef(recipeId).transaction((current) => {
170
+ if (!current) {
171
+ return {
172
+ comments: 0,
173
+ likes: 0,
174
+ rating,
175
+ ratingCount: 1,
176
+ saves: 0,
177
+ updatedAt: Date.now(),
178
+ views: 0,
179
+ };
180
+ }
181
+
182
+ let newCount = current.ratingCount;
183
+ let newRating: number;
184
+
185
+ if (previousRating !== undefined) {
186
+ // Updating existing rating
187
+ const totalRating = current.rating * current.ratingCount;
188
+ newRating = (totalRating - previousRating + rating) / newCount;
189
+ } else {
190
+ // New rating
191
+ newCount = current.ratingCount + 1;
192
+ newRating = (current.rating * current.ratingCount + rating) / newCount;
193
+ }
194
+
195
+ return {
196
+ ...current,
197
+ rating: newRating,
198
+ ratingCount: newCount,
199
+ updatedAt: Date.now(),
200
+ };
201
+ });
202
+
203
+ return this.get(recipeId);
204
+ }
205
+
206
+ async incrementComments(recipeId: string): Promise<RecipeStats> {
207
+ await this.getRef(recipeId).transaction((current) => {
208
+ if (!current) {
209
+ return {
210
+ comments: 1,
211
+ likes: 0,
212
+ rating: 0,
213
+ ratingCount: 0,
214
+ saves: 0,
215
+ updatedAt: Date.now(),
216
+ views: 0,
217
+ };
218
+ }
219
+ return {
220
+ ...current,
221
+ comments: Math.max(0, current.comments + 1),
222
+ updatedAt: Date.now(),
223
+ };
224
+ });
225
+
226
+ return this.get(recipeId);
227
+ }
228
+
229
+ async decrementComments(recipeId: string): Promise<RecipeStats> {
230
+ await this.getRef(recipeId).transaction((current) => {
231
+ 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
+ };
241
+ }
242
+ return {
243
+ ...current,
244
+ comments: Math.max(0, current.comments - 1),
245
+ updatedAt: Date.now(),
246
+ };
247
+ });
248
+
249
+ return this.get(recipeId);
250
+ }
251
+ }
@@ -0,0 +1,240 @@
1
+ import { Firestore, Timestamp } from "firebase-admin/firestore";
2
+ import type { Recipe, RecipeMeta, RecipeContent, Locale } from "@cravery/core";
3
+ import { recipeMetaConverter, recipeContentConverter } from "./converters";
4
+ import { mergeRecipe, splitRecipe } from "./utils";
5
+ import { RecipeRepository } from "./repository";
6
+
7
+ export class UserRecipeRepository {
8
+ constructor(private db: Firestore) {}
9
+
10
+ async toggleSave(
11
+ userId: string,
12
+ recipeId: string,
13
+ ): Promise<{ saved: boolean }> {
14
+ const saveRef = this.db
15
+ .collection("users")
16
+ .doc(userId)
17
+ .collection("saved")
18
+ .doc(recipeId);
19
+
20
+ const doc = await saveRef.get();
21
+
22
+ if (doc.exists) {
23
+ await saveRef.delete();
24
+ return { saved: false };
25
+ } else {
26
+ await saveRef.set({
27
+ recipeId,
28
+ savedAt: Timestamp.now(),
29
+ });
30
+ return { saved: true };
31
+ }
32
+ }
33
+
34
+ async toggleLike(
35
+ userId: string,
36
+ recipeId: string,
37
+ ): Promise<{ liked: boolean }> {
38
+ const likeRef = this.db
39
+ .collection("users")
40
+ .doc(userId)
41
+ .collection("likes")
42
+ .doc(recipeId);
43
+
44
+ const doc = await likeRef.get();
45
+
46
+ if (doc.exists) {
47
+ await likeRef.delete();
48
+ return { liked: false };
49
+ } else {
50
+ await likeRef.set({
51
+ recipeId,
52
+ likedAt: Timestamp.now(),
53
+ });
54
+ return { liked: true };
55
+ }
56
+ }
57
+
58
+ async rateRecipe(
59
+ userId: string,
60
+ recipeId: string,
61
+ rating: number,
62
+ ): Promise<{ previousRating?: number }> {
63
+ if (rating < 1 || rating > 5) {
64
+ throw new Error("Rating must be between 1 and 5");
65
+ }
66
+
67
+ const ratingRef = this.db
68
+ .collection("users")
69
+ .doc(userId)
70
+ .collection("ratings")
71
+ .doc(recipeId);
72
+
73
+ const doc = await ratingRef.get();
74
+ const previousRating = doc.exists ? doc.data()?.rating : undefined;
75
+
76
+ await ratingRef.set({
77
+ recipeId,
78
+ rating,
79
+ ratedAt: Timestamp.now(),
80
+ });
81
+
82
+ return { previousRating };
83
+ }
84
+
85
+ async cloneRecipe(
86
+ userId: string,
87
+ recipeId: string,
88
+ locale: Locale,
89
+ sourceRepository: RecipeRepository,
90
+ ): Promise<Recipe> {
91
+ // Check if already cloned
92
+ const existingDoc = await this.db
93
+ .collection("users")
94
+ .doc(userId)
95
+ .collection("recipes")
96
+ .doc(recipeId)
97
+ .get();
98
+
99
+ if (existingDoc.exists) {
100
+ throw new Error(`Recipe ${recipeId} already cloned for user ${userId}`);
101
+ }
102
+
103
+ // Fetch source recipe
104
+ const sourceRecipe = await sourceRepository.getById(recipeId, locale);
105
+ const { meta, content } = splitRecipe(sourceRecipe);
106
+
107
+ // Store in user's collection (same structure as main recipes)
108
+ const userMetaRef = this.db
109
+ .collection("users")
110
+ .doc(userId)
111
+ .collection("recipes")
112
+ .doc(recipeId)
113
+ .withConverter(recipeMetaConverter);
114
+
115
+ await userMetaRef.set(meta as RecipeMeta);
116
+
117
+ await this.db
118
+ .collection("users")
119
+ .doc(userId)
120
+ .collection("recipes")
121
+ .doc(recipeId)
122
+ .collection("content")
123
+ .doc(locale)
124
+ .withConverter(recipeContentConverter)
125
+ .set(content);
126
+
127
+ return sourceRecipe;
128
+ }
129
+
130
+ async getUserRecipe(
131
+ userId: string,
132
+ recipeId: string,
133
+ locale: Locale,
134
+ ): Promise<Recipe> {
135
+ const metaDoc = await this.db
136
+ .collection("users")
137
+ .doc(userId)
138
+ .collection("recipes")
139
+ .doc(recipeId)
140
+ .withConverter(recipeMetaConverter)
141
+ .get();
142
+
143
+ if (!metaDoc.exists) {
144
+ throw new Error(
145
+ `Recipe ${recipeId} not found in user ${userId}'s collection`,
146
+ );
147
+ }
148
+
149
+ const contentDoc = await this.db
150
+ .collection("users")
151
+ .doc(userId)
152
+ .collection("recipes")
153
+ .doc(recipeId)
154
+ .collection("content")
155
+ .doc(locale)
156
+ .withConverter(recipeContentConverter)
157
+ .get();
158
+
159
+ if (!contentDoc.exists) {
160
+ throw new Error(`Recipe ${recipeId} has no content for locale ${locale}`);
161
+ }
162
+
163
+ const meta = metaDoc.data()!;
164
+ const content = contentDoc.data()!;
165
+
166
+ return mergeRecipe(meta, content);
167
+ }
168
+
169
+ async updateUserRecipe(
170
+ userId: string,
171
+ recipeId: string,
172
+ locale: Locale,
173
+ updates: {
174
+ meta?: Partial<Omit<RecipeMeta, "id" | "createdAt" | "createdBy">>;
175
+ content?: Partial<RecipeContent>;
176
+ },
177
+ ): Promise<void> {
178
+ const baseRef = this.db
179
+ .collection("users")
180
+ .doc(userId)
181
+ .collection("recipes")
182
+ .doc(recipeId);
183
+
184
+ // Verify exists
185
+ const metaDoc = await baseRef.get();
186
+ if (!metaDoc.exists) {
187
+ throw new Error(
188
+ `Recipe ${recipeId} not found in user ${userId}'s collection`,
189
+ );
190
+ }
191
+
192
+ if (updates.meta) {
193
+ await baseRef.withConverter(recipeMetaConverter).update({
194
+ ...updates.meta,
195
+ updatedAt: Timestamp.now(),
196
+ } as any);
197
+ }
198
+
199
+ if (updates.content) {
200
+ await baseRef
201
+ .collection("content")
202
+ .doc(locale)
203
+ .withConverter(recipeContentConverter)
204
+ .set({ ...updates.content, locale } as RecipeContent, { merge: true });
205
+ }
206
+ }
207
+
208
+ async isSaved(userId: string, recipeId: string): Promise<boolean> {
209
+ const doc = await this.db
210
+ .collection("users")
211
+ .doc(userId)
212
+ .collection("saved")
213
+ .doc(recipeId)
214
+ .get();
215
+ return doc.exists;
216
+ }
217
+
218
+ async isLiked(userId: string, recipeId: string): Promise<boolean> {
219
+ const doc = await this.db
220
+ .collection("users")
221
+ .doc(userId)
222
+ .collection("likes")
223
+ .doc(recipeId)
224
+ .get();
225
+ return doc.exists;
226
+ }
227
+
228
+ async getUserRating(
229
+ userId: string,
230
+ recipeId: string,
231
+ ): Promise<number | null> {
232
+ const doc = await this.db
233
+ .collection("users")
234
+ .doc(userId)
235
+ .collection("ratings")
236
+ .doc(recipeId)
237
+ .get();
238
+ return doc.exists ? (doc.data()?.rating ?? null) : null;
239
+ }
240
+ }