@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.
- package/dist/recipe/converters.d.ts.map +1 -1
- package/dist/recipe/converters.js +2 -2
- package/dist/recipe/converters.js.map +1 -1
- package/dist/recipe/index.d.ts +2 -0
- package/dist/recipe/index.d.ts.map +1 -1
- package/dist/recipe/index.js +2 -0
- package/dist/recipe/index.js.map +1 -1
- package/dist/recipe/repository.d.ts +5 -0
- package/dist/recipe/repository.d.ts.map +1 -1
- package/dist/recipe/repository.js +46 -3
- package/dist/recipe/repository.js.map +1 -1
- package/dist/recipe/stats_repository.d.ts +18 -0
- package/dist/recipe/stats_repository.d.ts.map +1 -0
- package/dist/recipe/stats_repository.js +195 -0
- package/dist/recipe/stats_repository.js.map +1 -0
- package/dist/recipe/user_recipe_repository.d.ts +26 -0
- package/dist/recipe/user_recipe_repository.d.ts.map +1 -0
- package/dist/recipe/user_recipe_repository.js +180 -0
- package/dist/recipe/user_recipe_repository.js.map +1 -0
- package/dist/utils/strip-undefined.js +2 -2
- package/dist/utils/strip-undefined.js.map +1 -1
- package/dist/utils/timestamp.d.ts +1 -1
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/package.json +60 -58
- package/src/iam/converters.ts +38 -38
- package/src/iam/index.ts +1 -1
- package/src/index.ts +3 -3
- package/src/recipe/converters.ts +98 -93
- package/src/recipe/index.ts +5 -3
- package/src/recipe/repository.ts +284 -220
- package/src/recipe/stats_repository.ts +251 -0
- package/src/recipe/user_recipe_repository.ts +240 -0
- package/src/recipe/utils.ts +143 -143
- package/src/utils/index.ts +2 -2
- package/src/utils/strip-undefined.ts +32 -32
- 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
|
+
}
|