@cravery/firebase 0.0.7 → 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 (178) hide show
  1. package/dist/asset/repository.d.ts +5 -1
  2. package/dist/asset/repository.d.ts.map +1 -1
  3. package/dist/asset/repository.js +20 -1
  4. package/dist/asset/repository.js.map +1 -1
  5. package/dist/converter/comment.d.ts +4 -0
  6. package/dist/converter/comment.d.ts.map +1 -0
  7. package/dist/converter/comment.js +36 -0
  8. package/dist/converter/comment.js.map +1 -0
  9. package/dist/converter/equipment.d.ts +5 -0
  10. package/dist/converter/equipment.d.ts.map +1 -0
  11. package/dist/converter/equipment.js +49 -0
  12. package/dist/converter/equipment.js.map +1 -0
  13. package/dist/converter/index.d.ts +10 -0
  14. package/dist/converter/index.d.ts.map +1 -0
  15. package/dist/converter/index.js +26 -0
  16. package/dist/converter/index.js.map +1 -0
  17. package/dist/converter/ingredient.d.ts +5 -0
  18. package/dist/converter/ingredient.d.ts.map +1 -0
  19. package/dist/converter/ingredient.js +49 -0
  20. package/dist/converter/ingredient.js.map +1 -0
  21. package/dist/converter/profile.d.ts +4 -0
  22. package/dist/converter/profile.d.ts.map +1 -0
  23. package/dist/converter/profile.js +40 -0
  24. package/dist/converter/profile.js.map +1 -0
  25. package/dist/converter/recipe.d.ts +5 -0
  26. package/dist/converter/recipe.d.ts.map +1 -0
  27. package/dist/converter/recipe.js +75 -0
  28. package/dist/converter/recipe.js.map +1 -0
  29. package/dist/converter/report.d.ts +4 -0
  30. package/dist/converter/report.d.ts.map +1 -0
  31. package/dist/converter/report.js +46 -0
  32. package/dist/converter/report.js.map +1 -0
  33. package/dist/converter/settings.d.ts +4 -0
  34. package/dist/converter/settings.d.ts.map +1 -0
  35. package/dist/converter/settings.js +44 -0
  36. package/dist/converter/settings.js.map +1 -0
  37. package/dist/converter/submission.d.ts +4 -0
  38. package/dist/converter/submission.d.ts.map +1 -0
  39. package/dist/converter/submission.js +41 -0
  40. package/dist/converter/submission.js.map +1 -0
  41. package/dist/converter/user.d.ts +4 -0
  42. package/dist/converter/user.d.ts.map +1 -0
  43. package/dist/converter/user.js +39 -0
  44. package/dist/converter/user.js.map +1 -0
  45. package/dist/equipment/equipment.repository.d.ts +6 -0
  46. package/dist/equipment/equipment.repository.d.ts.map +1 -0
  47. package/dist/equipment/equipment.repository.js +11 -0
  48. package/dist/equipment/equipment.repository.js.map +1 -0
  49. package/dist/equipment/index.d.ts +2 -0
  50. package/dist/equipment/index.d.ts.map +1 -0
  51. package/dist/equipment/index.js +18 -0
  52. package/dist/equipment/index.js.map +1 -0
  53. package/dist/iam/index.d.ts +2 -1
  54. package/dist/iam/index.d.ts.map +1 -1
  55. package/dist/iam/index.js +2 -1
  56. package/dist/iam/index.js.map +1 -1
  57. package/dist/iam/profile.converter.d.ts +4 -0
  58. package/dist/iam/profile.converter.d.ts.map +1 -0
  59. package/dist/iam/profile.converter.js +40 -0
  60. package/dist/iam/profile.converter.js.map +1 -0
  61. package/dist/iam/user.converter.d.ts +4 -0
  62. package/dist/iam/user.converter.d.ts.map +1 -0
  63. package/dist/iam/user.converter.js +39 -0
  64. package/dist/iam/user.converter.js.map +1 -0
  65. package/dist/index.d.ts +3 -3
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +3 -3
  68. package/dist/index.js.map +1 -1
  69. package/dist/ingredient/index.d.ts +2 -0
  70. package/dist/ingredient/index.d.ts.map +1 -0
  71. package/dist/ingredient/index.js +18 -0
  72. package/dist/ingredient/index.js.map +1 -0
  73. package/dist/ingredient/ingredient.repository.d.ts +6 -0
  74. package/dist/ingredient/ingredient.repository.d.ts.map +1 -0
  75. package/dist/ingredient/ingredient.repository.js +11 -0
  76. package/dist/ingredient/ingredient.repository.js.map +1 -0
  77. package/dist/lib/asset.repository.d.ts +13 -0
  78. package/dist/lib/asset.repository.d.ts.map +1 -0
  79. package/dist/lib/asset.repository.js +65 -0
  80. package/dist/lib/asset.repository.js.map +1 -0
  81. package/dist/lib/index.d.ts +2 -0
  82. package/dist/lib/index.d.ts.map +1 -0
  83. package/dist/lib/index.js +18 -0
  84. package/dist/lib/index.js.map +1 -0
  85. package/dist/recipe/index.d.ts +2 -2
  86. package/dist/recipe/index.d.ts.map +1 -1
  87. package/dist/recipe/index.js +2 -2
  88. package/dist/recipe/index.js.map +1 -1
  89. package/dist/recipe/recipe.converter.d.ts +5 -0
  90. package/dist/recipe/recipe.converter.d.ts.map +1 -0
  91. package/dist/recipe/recipe.converter.js +86 -0
  92. package/dist/recipe/recipe.converter.js.map +1 -0
  93. package/dist/recipe/recipe.repository.d.ts +22 -0
  94. package/dist/recipe/recipe.repository.d.ts.map +1 -0
  95. package/dist/recipe/recipe.repository.js +210 -0
  96. package/dist/recipe/recipe.repository.js.map +1 -0
  97. package/dist/recipe/user_recipe_repository.d.ts +1 -1
  98. package/dist/recipe/user_recipe_repository.d.ts.map +1 -1
  99. package/dist/recipe/user_recipe_repository.js +7 -7
  100. package/dist/recipe/user_recipe_repository.js.map +1 -1
  101. package/dist/repository/base.d.ts +32 -0
  102. package/dist/repository/base.d.ts.map +1 -0
  103. package/dist/repository/base.js +167 -0
  104. package/dist/repository/base.js.map +1 -0
  105. package/dist/repository/equipment.d.ts +14 -0
  106. package/dist/repository/equipment.d.ts.map +1 -0
  107. package/dist/repository/equipment.js +33 -0
  108. package/dist/repository/equipment.js.map +1 -0
  109. package/dist/repository/index.d.ts +6 -0
  110. package/dist/repository/index.d.ts.map +1 -0
  111. package/dist/repository/index.js +22 -0
  112. package/dist/repository/index.js.map +1 -0
  113. package/dist/repository/ingredient.d.ts +14 -0
  114. package/dist/repository/ingredient.d.ts.map +1 -0
  115. package/dist/repository/ingredient.js +33 -0
  116. package/dist/repository/ingredient.js.map +1 -0
  117. package/dist/repository/recipe.d.ts +17 -0
  118. package/dist/repository/recipe.d.ts.map +1 -0
  119. package/dist/repository/recipe.js +92 -0
  120. package/dist/repository/recipe.js.map +1 -0
  121. package/dist/repository/recipe_stats.d.ts +19 -0
  122. package/dist/repository/recipe_stats.d.ts.map +1 -0
  123. package/dist/repository/recipe_stats.js +121 -0
  124. package/dist/repository/recipe_stats.js.map +1 -0
  125. package/dist/repository/user_recipe.d.ts +39 -0
  126. package/dist/repository/user_recipe.d.ts.map +1 -0
  127. package/dist/repository/user_recipe.js +160 -0
  128. package/dist/repository/user_recipe.js.map +1 -0
  129. package/dist/transform/index.d.ts +2 -0
  130. package/dist/transform/index.d.ts.map +1 -0
  131. package/dist/transform/index.js +18 -0
  132. package/dist/transform/index.js.map +1 -0
  133. package/dist/transform/recipe.d.ts +7 -0
  134. package/dist/transform/recipe.d.ts.map +1 -0
  135. package/dist/transform/recipe.js +118 -0
  136. package/dist/transform/recipe.js.map +1 -0
  137. package/dist/utils/cursor.d.ts +10 -0
  138. package/dist/utils/cursor.d.ts.map +1 -0
  139. package/dist/utils/cursor.js +31 -0
  140. package/dist/utils/cursor.js.map +1 -0
  141. package/dist/utils/index.d.ts +1 -0
  142. package/dist/utils/index.d.ts.map +1 -1
  143. package/dist/utils/index.js +1 -0
  144. package/dist/utils/index.js.map +1 -1
  145. package/dist/utils/timestamp.d.ts +2 -0
  146. package/dist/utils/timestamp.d.ts.map +1 -1
  147. package/dist/utils/timestamp.js +19 -0
  148. package/dist/utils/timestamp.js.map +1 -1
  149. package/package.json +59 -59
  150. package/src/converter/comment.ts +41 -0
  151. package/src/converter/equipment.ts +58 -0
  152. package/src/converter/index.ts +9 -0
  153. package/src/converter/ingredient.ts +58 -0
  154. package/src/converter/profile.ts +44 -0
  155. package/src/{recipe/converters.ts → converter/recipe.ts} +84 -98
  156. package/src/converter/report.ts +52 -0
  157. package/src/converter/settings.ts +49 -0
  158. package/src/converter/submission.ts +47 -0
  159. package/src/{iam/converters.ts → converter/user.ts} +10 -5
  160. package/src/index.ts +3 -3
  161. package/src/repository/base.ts +274 -0
  162. package/src/repository/equipment.ts +62 -0
  163. package/src/repository/index.ts +5 -0
  164. package/src/repository/ingredient.ts +62 -0
  165. package/src/repository/recipe.ts +147 -0
  166. package/src/{recipe/stats_repository.ts → repository/recipe_stats.ts} +22 -81
  167. package/src/repository/user_recipe.ts +225 -0
  168. package/src/transform/index.ts +1 -0
  169. package/src/{recipe/utils.ts → transform/recipe.ts} +143 -143
  170. package/src/utils/cursor.ts +34 -0
  171. package/src/utils/index.ts +1 -0
  172. package/src/utils/timestamp.ts +20 -0
  173. package/src/asset/index.ts +0 -1
  174. package/src/asset/repository.ts +0 -111
  175. package/src/iam/index.ts +0 -1
  176. package/src/recipe/index.ts +0 -5
  177. package/src/recipe/repository.ts +0 -284
  178. package/src/recipe/user_recipe_repository.ts +0 -240
@@ -0,0 +1,274 @@
1
+ import {
2
+ FieldPath,
3
+ Firestore,
4
+ Timestamp,
5
+ FirestoreDataConverter,
6
+ Query,
7
+ } from "firebase-admin/firestore";
8
+ import {
9
+ type Entity,
10
+ type Locale,
11
+ type CursorPaginatedResult,
12
+ type PaginationDirection,
13
+ COLLECTIONS,
14
+ } from "@cravery/core";
15
+ import { encodeCursor, cursorToFirestoreValues } from "../utils/cursor";
16
+
17
+ export interface SplitResult<TMeta, TContent> {
18
+ meta: Omit<TMeta, "id">;
19
+ content: TContent;
20
+ }
21
+
22
+ export abstract class BaseRepository<
23
+ TEntity extends TMeta & TContent,
24
+ TMeta extends Entity,
25
+ TContent,
26
+ > {
27
+ protected abstract readonly collectionName: string;
28
+ protected abstract readonly entityName: string;
29
+ protected abstract readonly metaConverter: FirestoreDataConverter<TMeta>;
30
+ protected abstract readonly contentConverter: FirestoreDataConverter<TContent>;
31
+
32
+ constructor(protected db: Firestore) {}
33
+
34
+ protected abstract merge(meta: TMeta, content: TContent): TEntity;
35
+ protected abstract split(entity: TEntity): SplitResult<TMeta, TContent>;
36
+
37
+ protected get metaCollection() {
38
+ return this.db
39
+ .collection(this.collectionName)
40
+ .withConverter(this.metaConverter);
41
+ }
42
+
43
+ protected contentCollection(entityId: string) {
44
+ return this.db
45
+ .collection(this.collectionName)
46
+ .doc(entityId)
47
+ .collection(COLLECTIONS.Content)
48
+ .withConverter(this.contentConverter);
49
+ }
50
+
51
+ async create(
52
+ entity: Omit<TEntity, "id">,
53
+ locale: Locale,
54
+ id?: string,
55
+ ): Promise<TEntity> {
56
+ const { meta, content } = this.split(entity as TEntity);
57
+
58
+ let entityId: string;
59
+ if (id) {
60
+ await this.metaCollection.doc(id).set(meta as TMeta);
61
+ entityId = id;
62
+ } else {
63
+ const metaRef = await this.metaCollection.add(meta as TMeta);
64
+ entityId = metaRef.id;
65
+ }
66
+
67
+ await this.contentCollection(entityId).doc(locale).set(content);
68
+ return { id: entityId, ...entity } as TEntity;
69
+ }
70
+
71
+ async getById(entityId: string, locale: Locale): Promise<TEntity> {
72
+ const [metaDoc, contentDoc] = await Promise.all([
73
+ this.metaCollection.doc(entityId).get(),
74
+ this.contentCollection(entityId).doc(locale).get(),
75
+ ]);
76
+
77
+ if (!metaDoc.exists) {
78
+ throw new Error(`${this.entityName} ${entityId} not found`);
79
+ }
80
+
81
+ const meta = metaDoc.data()!;
82
+
83
+ if (!contentDoc.exists) {
84
+ throw new Error(
85
+ `${this.entityName} ${entityId} has no content for locale ${locale}`,
86
+ );
87
+ }
88
+
89
+ const content = contentDoc.data()!;
90
+ return this.merge(meta, content);
91
+ }
92
+
93
+ async getMeta(entityId: string): Promise<TMeta> {
94
+ const metaDoc = await this.metaCollection.doc(entityId).get();
95
+
96
+ if (!metaDoc.exists) {
97
+ throw new Error(`${this.entityName} ${entityId} not found`);
98
+ }
99
+
100
+ return metaDoc.data()!;
101
+ }
102
+
103
+ async getContent(entityId: string, locale: Locale): Promise<TContent> {
104
+ const contentDoc = await this.contentCollection(entityId).doc(locale).get();
105
+
106
+ if (!contentDoc.exists) {
107
+ throw new Error(
108
+ `${this.entityName} ${entityId} has no content for locale ${locale}`,
109
+ );
110
+ }
111
+
112
+ return contentDoc.data()!;
113
+ }
114
+
115
+ async updateMeta(
116
+ entityId: string,
117
+ updates: Partial<Omit<TMeta, "id" | "createdAt">>,
118
+ ): Promise<void> {
119
+ await this.metaCollection.doc(entityId).update({
120
+ ...updates,
121
+ updatedAt: Timestamp.now(),
122
+ });
123
+ }
124
+
125
+ async updateContent(
126
+ entityId: string,
127
+ locale: Locale,
128
+ content: Partial<TContent>,
129
+ ): Promise<void> {
130
+ await this.contentCollection(entityId)
131
+ .doc(locale)
132
+ .set(content, { merge: true });
133
+ }
134
+
135
+ async createContent(
136
+ entityId: string,
137
+ locale: Locale,
138
+ content: TContent,
139
+ ): Promise<void> {
140
+ const metaDoc = await this.metaCollection.doc(entityId).get();
141
+ if (!metaDoc.exists) {
142
+ throw new Error(`${this.entityName} ${entityId} not found`);
143
+ }
144
+
145
+ const existingContent = await this.contentCollection(entityId)
146
+ .doc(locale)
147
+ .get();
148
+
149
+ if (existingContent.exists) {
150
+ throw new Error(
151
+ `Content for ${this.entityName.toLowerCase()} ${entityId} in locale ${locale} already exists. Use updateContent() instead.`,
152
+ );
153
+ }
154
+
155
+ await this.contentCollection(entityId).doc(locale).set(content);
156
+ }
157
+
158
+ async getAllContent(entityId: string): Promise<TContent[]> {
159
+ const metaDoc = await this.metaCollection.doc(entityId).get();
160
+ if (!metaDoc.exists) {
161
+ throw new Error(`${this.entityName} ${entityId} not found`);
162
+ }
163
+
164
+ const snapshot = await this.contentCollection(entityId).get();
165
+
166
+ if (snapshot.empty) {
167
+ throw new Error(`${this.entityName} ${entityId} has no content`);
168
+ }
169
+
170
+ return snapshot.docs.map((doc) => doc.data());
171
+ }
172
+
173
+ async delete(entityId: string): Promise<void> {
174
+ await this.metaCollection.doc(entityId).update({
175
+ status: "deleted",
176
+ deletedAt: Timestamp.now(),
177
+ updatedAt: Timestamp.now(),
178
+ });
179
+ }
180
+
181
+ async getPaginated(
182
+ locale: Locale,
183
+ limit = 20,
184
+ cursor?: string,
185
+ direction: PaginationDirection = "forward",
186
+ ): Promise<CursorPaginatedResult<TEntity>> {
187
+ const baseQuery = this.metaCollection.where("status", "!=", "deleted");
188
+ return this.executePaginatedQuery(baseQuery, locale, limit, cursor, direction);
189
+ }
190
+
191
+ async exists(entityId: string): Promise<boolean> {
192
+ const doc = await this.metaCollection.doc(entityId).get();
193
+ return doc.exists;
194
+ }
195
+
196
+ protected async executePaginatedQuery(
197
+ baseQuery: Query<TMeta>,
198
+ locale: Locale,
199
+ limit: number,
200
+ cursor?: string,
201
+ direction: PaginationDirection = "forward",
202
+ ): Promise<CursorPaginatedResult<TEntity>> {
203
+ const isForward = direction === "forward";
204
+ const sortDir = isForward ? "desc" : "asc";
205
+
206
+ let query = baseQuery
207
+ .orderBy("createdAt", sortDir)
208
+ .orderBy(FieldPath.documentId(), sortDir);
209
+
210
+ if (cursor) {
211
+ const [timestamp, id] = cursorToFirestoreValues(cursor);
212
+ query = query.startAfter(timestamp, id);
213
+ }
214
+
215
+ const snapshot = await query.limit(limit + 1).get();
216
+ const hasMore = snapshot.docs.length > limit;
217
+ let docs = hasMore ? snapshot.docs.slice(0, -1) : snapshot.docs;
218
+
219
+ if (!isForward) {
220
+ docs = docs.reverse();
221
+ }
222
+
223
+ if (docs.length === 0) {
224
+ return {
225
+ data: [],
226
+ startCursor: null,
227
+ endCursor: null,
228
+ hasNextPage: false,
229
+ hasPreviousPage: false,
230
+ };
231
+ }
232
+
233
+ const entities = await this.hydrate(docs, locale);
234
+
235
+ const firstDoc = docs[0];
236
+ const lastDoc = docs[docs.length - 1];
237
+
238
+ const firstCreatedAt = firstDoc.get("createdAt") as Timestamp;
239
+ const lastCreatedAt = lastDoc.get("createdAt") as Timestamp;
240
+
241
+ return {
242
+ data: entities,
243
+ startCursor: encodeCursor(firstCreatedAt, firstDoc.id),
244
+ endCursor: encodeCursor(lastCreatedAt, lastDoc.id),
245
+ hasNextPage: isForward ? hasMore : !!cursor,
246
+ hasPreviousPage: isForward ? !!cursor : hasMore,
247
+ };
248
+ }
249
+
250
+ protected async hydrate(
251
+ docs: FirebaseFirestore.QueryDocumentSnapshot<TMeta>[],
252
+ locale: Locale,
253
+ ): Promise<TEntity[]> {
254
+ const contentDocs = await Promise.all(
255
+ docs.map((metaDoc) =>
256
+ this.contentCollection(metaDoc.id).doc(locale).get(),
257
+ ),
258
+ );
259
+
260
+ return docs
261
+ .map((metaDoc, index) => {
262
+ const meta = metaDoc.data();
263
+ const contentDoc = contentDocs[index];
264
+
265
+ if (!contentDoc.exists) {
266
+ return null;
267
+ }
268
+
269
+ const content = contentDoc.data()!;
270
+ return this.merge(meta, content);
271
+ })
272
+ .filter((item): item is TEntity => item !== null);
273
+ }
274
+ }
@@ -0,0 +1,62 @@
1
+ import { Firestore } from "firebase-admin/firestore";
2
+ import {
3
+ type EquipmentEntity,
4
+ type EquipmentEntityMeta,
5
+ type EquipmentEntityContent,
6
+ type EquipmentCategory,
7
+ type Locale,
8
+ type CursorPaginatedResult,
9
+ type PaginationDirection,
10
+ COLLECTIONS,
11
+ } from "@cravery/core";
12
+ import {
13
+ equipmentMetaConverter,
14
+ equipmentContentConverter,
15
+ } from "../converter";
16
+ import { BaseRepository, SplitResult } from "./base";
17
+
18
+ export class EquipmentRepository extends BaseRepository<
19
+ EquipmentEntity,
20
+ EquipmentEntityMeta,
21
+ EquipmentEntityContent
22
+ > {
23
+ protected readonly collectionName = COLLECTIONS.Equipment;
24
+ protected readonly entityName = "Equipment";
25
+ protected readonly metaConverter = equipmentMetaConverter;
26
+ protected readonly contentConverter = equipmentContentConverter;
27
+
28
+ constructor(db: Firestore) {
29
+ super(db);
30
+ }
31
+
32
+ protected merge(
33
+ meta: EquipmentEntityMeta,
34
+ content: EquipmentEntityContent,
35
+ ): EquipmentEntity {
36
+ return { ...meta, ...content };
37
+ }
38
+
39
+ protected split(
40
+ equipment: EquipmentEntity,
41
+ ): SplitResult<EquipmentEntityMeta, EquipmentEntityContent> {
42
+ const { id: _id, createdAt, updatedAt, deletedAt, status, category, name, slug } = equipment;
43
+ return {
44
+ meta: { category, slug, status, createdAt, updatedAt, deletedAt },
45
+ content: { name, slug },
46
+ };
47
+ }
48
+
49
+ async getByCategory(
50
+ category: EquipmentCategory,
51
+ locale: Locale,
52
+ limit = 20,
53
+ cursor?: string,
54
+ direction: PaginationDirection = "forward",
55
+ ): Promise<CursorPaginatedResult<EquipmentEntity>> {
56
+ const baseQuery = this.metaCollection
57
+ .where("category", "==", category)
58
+ .where("status", "!=", "deleted");
59
+
60
+ return this.executePaginatedQuery(baseQuery, locale, limit, cursor, direction);
61
+ }
62
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./base";
2
+ export * from "./equipment";
3
+ export * from "./ingredient";
4
+ export * from "./recipe";
5
+ export * from "./user_recipe";
@@ -0,0 +1,62 @@
1
+ import { Firestore } from "firebase-admin/firestore";
2
+ import {
3
+ type IngredientEntity,
4
+ type IngredientEntityMeta,
5
+ type IngredientEntityContent,
6
+ type IngredientCategory,
7
+ type Locale,
8
+ type CursorPaginatedResult,
9
+ type PaginationDirection,
10
+ COLLECTIONS,
11
+ } from "@cravery/core";
12
+ import {
13
+ ingredientMetaConverter,
14
+ ingredientContentConverter,
15
+ } from "../converter";
16
+ import { BaseRepository, SplitResult } from "./base";
17
+
18
+ export class IngredientRepository extends BaseRepository<
19
+ IngredientEntity,
20
+ IngredientEntityMeta,
21
+ IngredientEntityContent
22
+ > {
23
+ protected readonly collectionName = COLLECTIONS.Ingredients;
24
+ protected readonly entityName = "Ingredient";
25
+ protected readonly metaConverter = ingredientMetaConverter;
26
+ protected readonly contentConverter = ingredientContentConverter;
27
+
28
+ constructor(db: Firestore) {
29
+ super(db);
30
+ }
31
+
32
+ protected merge(
33
+ meta: IngredientEntityMeta,
34
+ content: IngredientEntityContent,
35
+ ): IngredientEntity {
36
+ return { ...meta, ...content };
37
+ }
38
+
39
+ protected split(
40
+ ingredient: IngredientEntity,
41
+ ): SplitResult<IngredientEntityMeta, IngredientEntityContent> {
42
+ const { id: _id, createdAt, updatedAt, deletedAt, status, category, name, slug } = ingredient;
43
+ return {
44
+ meta: { category, slug, status, createdAt, updatedAt, deletedAt },
45
+ content: { name, slug },
46
+ };
47
+ }
48
+
49
+ async getByCategory(
50
+ category: IngredientCategory,
51
+ locale: Locale,
52
+ limit = 20,
53
+ cursor?: string,
54
+ direction: PaginationDirection = "forward",
55
+ ): Promise<CursorPaginatedResult<IngredientEntity>> {
56
+ const baseQuery = this.metaCollection
57
+ .where("category", "==", category)
58
+ .where("status", "!=", "deleted");
59
+
60
+ return this.executePaginatedQuery(baseQuery, locale, limit, cursor, direction);
61
+ }
62
+ }
@@ -0,0 +1,147 @@
1
+ import { FieldPath, Firestore, Timestamp } from "firebase-admin/firestore";
2
+ import {
3
+ type Recipe,
4
+ type RecipeMeta,
5
+ type RecipeContent,
6
+ type Locale,
7
+ type CursorPaginatedResult,
8
+ type PaginationDirection,
9
+ COLLECTIONS,
10
+ EntityStatus,
11
+ } from "@cravery/core";
12
+ import { BaseRepository, SplitResult } from "./base";
13
+ import {
14
+ recipeMetaConverter,
15
+ recipeContentConverter,
16
+ } from "../converter";
17
+ import { mergeRecipe, splitRecipe } from "../transform";
18
+ import { encodeCursor, cursorToFirestoreValues } from "../utils";
19
+
20
+ export class RecipeRepository extends BaseRepository<
21
+ Recipe,
22
+ RecipeMeta,
23
+ RecipeContent
24
+ > {
25
+ protected readonly collectionName = COLLECTIONS.Recipes;
26
+ protected readonly entityName = "Recipe";
27
+ protected readonly metaConverter = recipeMetaConverter;
28
+ protected readonly contentConverter = recipeContentConverter;
29
+
30
+ constructor(db: Firestore) {
31
+ super(db);
32
+ }
33
+
34
+ protected merge(meta: RecipeMeta, content: RecipeContent): Recipe {
35
+ return mergeRecipe(meta, content);
36
+ }
37
+
38
+ protected split(recipe: Recipe): SplitResult<RecipeMeta, RecipeContent> {
39
+ return splitRecipe(recipe);
40
+ }
41
+
42
+ override async updateMeta(
43
+ recipeId: string,
44
+ updates: Partial<Omit<RecipeMeta, "id" | "createdAt" | "createdBy">>,
45
+ ): Promise<void> {
46
+ await this.metaCollection.doc(recipeId).update({
47
+ ...updates,
48
+ updatedAt: Timestamp.now(),
49
+ });
50
+ }
51
+
52
+ override async updateContent(
53
+ recipeId: string,
54
+ locale: Locale,
55
+ content: Partial<RecipeContent>,
56
+ ): Promise<void> {
57
+ await this.contentCollection(recipeId)
58
+ .doc(locale)
59
+ .set(
60
+ {
61
+ ...content,
62
+ locale,
63
+ },
64
+ { merge: true },
65
+ );
66
+ }
67
+
68
+ async getByStatus(
69
+ statuses: EntityStatus[],
70
+ locale: Locale,
71
+ limit = 20,
72
+ cursor?: string,
73
+ direction: PaginationDirection = "forward",
74
+ ): Promise<CursorPaginatedResult<Recipe>> {
75
+ const isForward = direction === "forward";
76
+ const sortDir = isForward ? "desc" : "asc";
77
+
78
+ let query = this.metaCollection
79
+ .where("status", "in", statuses)
80
+ .orderBy("createdAt", sortDir)
81
+ .orderBy(FieldPath.documentId(), sortDir);
82
+
83
+ if (cursor) {
84
+ const [timestamp, id] = cursorToFirestoreValues(cursor);
85
+ query = query.startAfter(timestamp, id);
86
+ }
87
+
88
+ const snapshot = await query.limit(limit + 1).get();
89
+ const hasMore = snapshot.docs.length > limit;
90
+ let docs = hasMore ? snapshot.docs.slice(0, -1) : snapshot.docs;
91
+
92
+ if (!isForward) {
93
+ docs = docs.reverse();
94
+ }
95
+
96
+ if (docs.length === 0) {
97
+ return {
98
+ data: [],
99
+ startCursor: null,
100
+ endCursor: null,
101
+ hasNextPage: false,
102
+ hasPreviousPage: false,
103
+ };
104
+ }
105
+
106
+ const recipes = await this.hydrate(docs, locale);
107
+
108
+ const firstDoc = docs[0];
109
+ const lastDoc = docs[docs.length - 1];
110
+
111
+ const firstCreatedAt = firstDoc.get("createdAt") as Timestamp;
112
+ const lastCreatedAt = lastDoc.get("createdAt") as Timestamp;
113
+
114
+ return {
115
+ data: recipes,
116
+ startCursor: encodeCursor(firstCreatedAt, firstDoc.id),
117
+ endCursor: encodeCursor(lastCreatedAt, lastDoc.id),
118
+ hasNextPage: isForward ? hasMore : !!cursor,
119
+ hasPreviousPage: isForward ? !!cursor : hasMore,
120
+ };
121
+ }
122
+
123
+ async findBySourceUrl(sourceUrl: string, locale: Locale): Promise<Recipe | null> {
124
+ const metaSnapshot = await this.metaCollection
125
+ .where("sourceUrl", "==", sourceUrl)
126
+ .limit(1)
127
+ .get();
128
+
129
+ if (metaSnapshot.empty) {
130
+ return null;
131
+ }
132
+
133
+ const metaDoc = metaSnapshot.docs[0];
134
+ const meta = metaDoc.data();
135
+
136
+ const contentDoc = await this.contentCollection(metaDoc.id)
137
+ .doc(locale)
138
+ .get();
139
+
140
+ if (!contentDoc.exists) {
141
+ throw new Error(`Recipe ${metaDoc.id} has no content for locale ${locale}`);
142
+ }
143
+
144
+ const content = contentDoc.data()!;
145
+ return this.merge(meta, content);
146
+ }
147
+ }