@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.
- package/dist/converter/comment.d.ts +4 -0
- package/dist/converter/comment.d.ts.map +1 -0
- package/dist/converter/comment.js +36 -0
- package/dist/converter/comment.js.map +1 -0
- package/dist/converter/equipment.d.ts +5 -0
- package/dist/converter/equipment.d.ts.map +1 -0
- package/dist/converter/equipment.js +49 -0
- package/dist/converter/equipment.js.map +1 -0
- package/dist/converter/index.d.ts +10 -0
- package/dist/converter/index.d.ts.map +1 -0
- package/dist/converter/index.js +26 -0
- package/dist/converter/index.js.map +1 -0
- package/dist/converter/ingredient.d.ts +5 -0
- package/dist/converter/ingredient.d.ts.map +1 -0
- package/dist/converter/ingredient.js +49 -0
- package/dist/converter/ingredient.js.map +1 -0
- package/dist/converter/profile.d.ts +4 -0
- package/dist/converter/profile.d.ts.map +1 -0
- package/dist/converter/profile.js +40 -0
- package/dist/converter/profile.js.map +1 -0
- package/dist/converter/recipe.d.ts +5 -0
- package/dist/converter/recipe.d.ts.map +1 -0
- package/dist/converter/recipe.js +75 -0
- package/dist/converter/recipe.js.map +1 -0
- package/dist/converter/report.d.ts +4 -0
- package/dist/converter/report.d.ts.map +1 -0
- package/dist/converter/report.js +46 -0
- package/dist/converter/report.js.map +1 -0
- package/dist/converter/settings.d.ts +4 -0
- package/dist/converter/settings.d.ts.map +1 -0
- package/dist/converter/settings.js +44 -0
- package/dist/converter/settings.js.map +1 -0
- package/dist/converter/submission.d.ts +4 -0
- package/dist/converter/submission.d.ts.map +1 -0
- package/dist/converter/submission.js +41 -0
- package/dist/converter/submission.js.map +1 -0
- package/dist/converter/user.d.ts +4 -0
- package/dist/converter/user.d.ts.map +1 -0
- package/dist/converter/user.js +39 -0
- package/dist/converter/user.js.map +1 -0
- package/dist/equipment/equipment.repository.d.ts +6 -0
- package/dist/equipment/equipment.repository.d.ts.map +1 -0
- package/dist/equipment/equipment.repository.js +11 -0
- package/dist/equipment/equipment.repository.js.map +1 -0
- package/dist/equipment/index.d.ts +2 -0
- package/dist/equipment/index.d.ts.map +1 -0
- package/dist/equipment/index.js +18 -0
- package/dist/equipment/index.js.map +1 -0
- package/dist/iam/index.d.ts +2 -1
- package/dist/iam/index.d.ts.map +1 -1
- package/dist/iam/index.js +2 -1
- package/dist/iam/index.js.map +1 -1
- package/dist/iam/profile.converter.d.ts +4 -0
- package/dist/iam/profile.converter.d.ts.map +1 -0
- package/dist/iam/profile.converter.js +40 -0
- package/dist/iam/profile.converter.js.map +1 -0
- package/dist/iam/user.converter.d.ts +4 -0
- package/dist/iam/user.converter.d.ts.map +1 -0
- package/dist/iam/user.converter.js +39 -0
- package/dist/iam/user.converter.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/ingredient/index.d.ts +2 -0
- package/dist/ingredient/index.d.ts.map +1 -0
- package/dist/ingredient/index.js +18 -0
- package/dist/ingredient/index.js.map +1 -0
- package/dist/ingredient/ingredient.repository.d.ts +6 -0
- package/dist/ingredient/ingredient.repository.d.ts.map +1 -0
- package/dist/ingredient/ingredient.repository.js +11 -0
- package/dist/ingredient/ingredient.repository.js.map +1 -0
- package/dist/lib/asset.repository.d.ts +13 -0
- package/dist/lib/asset.repository.d.ts.map +1 -0
- package/dist/lib/asset.repository.js +65 -0
- package/dist/lib/asset.repository.js.map +1 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +18 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/recipe/index.d.ts +2 -2
- package/dist/recipe/index.d.ts.map +1 -1
- package/dist/recipe/index.js +2 -2
- package/dist/recipe/index.js.map +1 -1
- package/dist/recipe/recipe.converter.d.ts +5 -0
- package/dist/recipe/recipe.converter.d.ts.map +1 -0
- package/dist/recipe/recipe.converter.js +86 -0
- package/dist/recipe/recipe.converter.js.map +1 -0
- package/dist/recipe/recipe.repository.d.ts +22 -0
- package/dist/recipe/recipe.repository.d.ts.map +1 -0
- package/dist/recipe/recipe.repository.js +210 -0
- package/dist/recipe/recipe.repository.js.map +1 -0
- package/dist/recipe/user_recipe_repository.d.ts +1 -1
- package/dist/recipe/user_recipe_repository.d.ts.map +1 -1
- package/dist/recipe/user_recipe_repository.js +7 -7
- package/dist/recipe/user_recipe_repository.js.map +1 -1
- package/dist/repository/base.d.ts +32 -0
- package/dist/repository/base.d.ts.map +1 -0
- package/dist/repository/base.js +167 -0
- package/dist/repository/base.js.map +1 -0
- package/dist/repository/equipment.d.ts +14 -0
- package/dist/repository/equipment.d.ts.map +1 -0
- package/dist/repository/equipment.js +33 -0
- package/dist/repository/equipment.js.map +1 -0
- package/dist/repository/index.d.ts +6 -0
- package/dist/repository/index.d.ts.map +1 -0
- package/dist/repository/index.js +22 -0
- package/dist/repository/index.js.map +1 -0
- package/dist/repository/ingredient.d.ts +14 -0
- package/dist/repository/ingredient.d.ts.map +1 -0
- package/dist/repository/ingredient.js +33 -0
- package/dist/repository/ingredient.js.map +1 -0
- package/dist/repository/recipe.d.ts +17 -0
- package/dist/repository/recipe.d.ts.map +1 -0
- package/dist/repository/recipe.js +92 -0
- package/dist/repository/recipe.js.map +1 -0
- package/dist/repository/recipe_stats.d.ts +19 -0
- package/dist/repository/recipe_stats.d.ts.map +1 -0
- package/dist/repository/recipe_stats.js +121 -0
- package/dist/repository/recipe_stats.js.map +1 -0
- package/dist/repository/user_recipe.d.ts +39 -0
- package/dist/repository/user_recipe.d.ts.map +1 -0
- package/dist/repository/user_recipe.js +160 -0
- package/dist/repository/user_recipe.js.map +1 -0
- package/dist/transform/index.d.ts +2 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/index.js +18 -0
- package/dist/transform/index.js.map +1 -0
- package/dist/transform/recipe.d.ts +7 -0
- package/dist/transform/recipe.d.ts.map +1 -0
- package/dist/transform/recipe.js +118 -0
- package/dist/transform/recipe.js.map +1 -0
- package/dist/utils/cursor.d.ts +10 -0
- package/dist/utils/cursor.d.ts.map +1 -0
- package/dist/utils/cursor.js +31 -0
- package/dist/utils/cursor.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/timestamp.d.ts +2 -0
- package/dist/utils/timestamp.d.ts.map +1 -1
- package/dist/utils/timestamp.js +19 -0
- package/dist/utils/timestamp.js.map +1 -1
- package/package.json +2 -2
- package/src/converter/comment.ts +41 -0
- package/src/converter/equipment.ts +58 -0
- package/src/converter/index.ts +9 -0
- package/src/converter/ingredient.ts +58 -0
- package/src/converter/profile.ts +44 -0
- package/src/{recipe/converters.ts → converter/recipe.ts} +6 -20
- package/src/converter/report.ts +52 -0
- package/src/converter/settings.ts +49 -0
- package/src/converter/submission.ts +47 -0
- package/src/{iam/converters.ts → converter/user.ts} +10 -5
- package/src/index.ts +3 -3
- package/src/repository/base.ts +274 -0
- package/src/repository/equipment.ts +62 -0
- package/src/repository/index.ts +5 -0
- package/src/repository/ingredient.ts +62 -0
- package/src/repository/recipe.ts +147 -0
- package/src/{recipe/stats_repository.ts → repository/recipe_stats.ts} +22 -81
- package/src/repository/user_recipe.ts +225 -0
- package/src/transform/index.ts +1 -0
- package/src/utils/cursor.ts +34 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/timestamp.ts +20 -0
- package/src/asset/index.ts +0 -1
- package/src/asset/repository.ts +0 -142
- package/src/iam/index.ts +0 -1
- package/src/recipe/index.ts +0 -5
- package/src/recipe/repository.ts +0 -284
- package/src/recipe/user_recipe_repository.ts +0 -240
- /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
|
|
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(`${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
package/src/utils/timestamp.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/asset/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./repository";
|
package/src/asset/repository.ts
DELETED
|
@@ -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";
|