@porulle/core 0.10.8 → 0.11.0

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 (114) hide show
  1. package/dist/config/types.d.ts +3 -0
  2. package/dist/config/types.d.ts.map +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/interfaces/rest/index.d.ts.map +1 -1
  7. package/dist/interfaces/rest/index.js +4 -0
  8. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
  9. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
  10. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
  11. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
  12. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
  13. package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
  14. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  15. package/dist/interfaces/rest/routes/catalog.js +37 -2
  16. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  17. package/dist/interfaces/rest/routes/search.js +12 -0
  18. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
  19. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
  20. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
  21. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
  22. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
  23. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
  24. package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
  25. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  26. package/dist/interfaces/rest/schemas/catalog.js +86 -0
  27. package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
  28. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  29. package/dist/interfaces/rest/schemas/search.d.ts +1 -0
  30. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  31. package/dist/interfaces/rest/schemas/search.js +1 -0
  32. package/dist/modules/catalog/entity-service.d.ts +5 -3
  33. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  34. package/dist/modules/catalog/entity-service.js +73 -22
  35. package/dist/modules/catalog/module.d.ts +6 -1
  36. package/dist/modules/catalog/module.d.ts.map +1 -1
  37. package/dist/modules/catalog/module.js +6 -1
  38. package/dist/modules/catalog/ownership.d.ts +5 -0
  39. package/dist/modules/catalog/ownership.d.ts.map +1 -0
  40. package/dist/modules/catalog/ownership.js +11 -0
  41. package/dist/modules/catalog/repository/index.d.ts +52 -1
  42. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  43. package/dist/modules/catalog/repository/index.js +321 -2
  44. package/dist/modules/catalog/revision.d.ts +4 -0
  45. package/dist/modules/catalog/revision.d.ts.map +1 -0
  46. package/dist/modules/catalog/revision.js +33 -0
  47. package/dist/modules/catalog/schema.d.ts +979 -110
  48. package/dist/modules/catalog/schema.d.ts.map +1 -1
  49. package/dist/modules/catalog/schema.js +112 -2
  50. package/dist/modules/catalog/schemas.d.ts +2 -0
  51. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  52. package/dist/modules/catalog/schemas.js +2 -0
  53. package/dist/modules/catalog/service.d.ts +66 -3
  54. package/dist/modules/catalog/service.d.ts.map +1 -1
  55. package/dist/modules/catalog/service.js +512 -11
  56. package/dist/modules/channels/adapter.d.ts +85 -0
  57. package/dist/modules/channels/adapter.d.ts.map +1 -1
  58. package/dist/modules/channels/adapter.js +9 -1
  59. package/dist/modules/media/module.d.ts +68 -0
  60. package/dist/modules/media/module.d.ts.map +1 -1
  61. package/dist/modules/media/module.js +2 -0
  62. package/dist/modules/media/schema.d.ts +70 -0
  63. package/dist/modules/media/schema.d.ts.map +1 -1
  64. package/dist/modules/media/schema.js +20 -2
  65. package/dist/modules/media/service.d.ts +8 -1
  66. package/dist/modules/media/service.d.ts.map +1 -1
  67. package/dist/modules/media/service.js +66 -18
  68. package/dist/modules/pricing/schema.d.ts +17 -0
  69. package/dist/modules/pricing/schema.d.ts.map +1 -1
  70. package/dist/modules/pricing/schema.js +1 -0
  71. package/dist/modules/pricing/schemas.d.ts +1 -0
  72. package/dist/modules/pricing/schemas.d.ts.map +1 -1
  73. package/dist/modules/pricing/schemas.js +1 -0
  74. package/dist/modules/pricing/service.d.ts.map +1 -1
  75. package/dist/modules/pricing/service.js +6 -1
  76. package/dist/modules/search/adapter.d.ts +2 -0
  77. package/dist/modules/search/adapter.d.ts.map +1 -1
  78. package/dist/modules/search/module.d.ts.map +1 -1
  79. package/dist/modules/search/module.js +2 -0
  80. package/dist/modules/search/service.d.ts +4 -0
  81. package/dist/modules/search/service.d.ts.map +1 -1
  82. package/dist/modules/search/service.js +74 -5
  83. package/dist/runtime/kernel-modules.d.ts +73 -0
  84. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/config/types.ts +3 -0
  87. package/src/index.ts +14 -0
  88. package/src/interfaces/rest/index.ts +4 -0
  89. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
  90. package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
  91. package/src/interfaces/rest/routes/catalog.ts +85 -0
  92. package/src/interfaces/rest/routes/search.ts +16 -0
  93. package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
  94. package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
  95. package/src/interfaces/rest/schemas/catalog.ts +92 -0
  96. package/src/interfaces/rest/schemas/search.ts +2 -0
  97. package/src/modules/catalog/entity-service.ts +73 -20
  98. package/src/modules/catalog/module.ts +15 -0
  99. package/src/modules/catalog/ownership.ts +17 -0
  100. package/src/modules/catalog/repository/index.ts +547 -1
  101. package/src/modules/catalog/revision.ts +35 -0
  102. package/src/modules/catalog/schema.ts +169 -1
  103. package/src/modules/catalog/schemas.ts +2 -0
  104. package/src/modules/catalog/service.ts +738 -13
  105. package/src/modules/channels/adapter.ts +107 -1
  106. package/src/modules/media/module.ts +2 -0
  107. package/src/modules/media/schema.ts +23 -2
  108. package/src/modules/media/service.ts +84 -23
  109. package/src/modules/pricing/schema.ts +1 -0
  110. package/src/modules/pricing/schemas.ts +1 -0
  111. package/src/modules/pricing/service.ts +7 -2
  112. package/src/modules/search/adapter.ts +2 -0
  113. package/src/modules/search/module.ts +2 -0
  114. package/src/modules/search/service.ts +87 -6
@@ -6,15 +6,20 @@ import { CatalogRepository } from "./repository/index.js";
6
6
  import {
7
7
  brands,
8
8
  categories,
9
+ tags,
10
+ entityTags,
9
11
  entityBrands,
10
12
  entityCategories,
11
13
  optionTypes,
12
14
  optionValues,
13
15
  sellableAttributes,
14
16
  sellableCustomFields,
17
+ entityFieldDefinitions,
18
+ sellableEntityRevisions,
15
19
  sellableEntities,
16
20
  variantOptionValues,
17
21
  variants,
22
+ catalogFieldOwnership,
18
23
  } from "./schema.js";
19
24
  import { CatalogServiceImpl } from "./service.js";
20
25
 
@@ -27,14 +32,19 @@ export const catalogModule = defineModule<
27
32
  sellableEntities: typeof sellableEntities;
28
33
  sellableAttributes: typeof sellableAttributes;
29
34
  sellableCustomFields: typeof sellableCustomFields;
35
+ entityFieldDefinitions: typeof entityFieldDefinitions;
36
+ sellableEntityRevisions: typeof sellableEntityRevisions;
30
37
  categories: typeof categories;
31
38
  entityCategories: typeof entityCategories;
32
39
  brands: typeof brands;
33
40
  entityBrands: typeof entityBrands;
41
+ tags: typeof tags;
42
+ entityTags: typeof entityTags;
34
43
  optionTypes: typeof optionTypes;
35
44
  optionValues: typeof optionValues;
36
45
  variants: typeof variants;
37
46
  variantOptionValues: typeof variantOptionValues;
47
+ catalogFieldOwnership: typeof catalogFieldOwnership;
38
48
  },
39
49
  CatalogServiceImpl,
40
50
  CatalogModuleDeps
@@ -45,14 +55,19 @@ export const catalogModule = defineModule<
45
55
  sellableEntities,
46
56
  sellableAttributes,
47
57
  sellableCustomFields,
58
+ entityFieldDefinitions,
59
+ sellableEntityRevisions,
48
60
  categories,
49
61
  entityCategories,
50
62
  brands,
51
63
  entityBrands,
64
+ tags,
65
+ entityTags,
52
66
  optionTypes,
53
67
  optionValues,
54
68
  variants,
55
69
  variantOptionValues,
70
+ catalogFieldOwnership,
56
71
  }),
57
72
  service: (deps) =>
58
73
  new CatalogServiceImpl({
@@ -0,0 +1,17 @@
1
+ import { CommerceValidationError } from "../../kernel/errors.js";
2
+
3
+ export type FieldPath = string;
4
+ export type FieldOwner = "platform" | "store" | "shared";
5
+
6
+ const fieldPathPattern = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
7
+
8
+ export function isValidFieldPath(value: string): value is FieldPath {
9
+ return fieldPathPattern.test(value);
10
+ }
11
+
12
+ export function validateFieldPath(value: string): FieldPath {
13
+ if (!isValidFieldPath(value)) {
14
+ throw new CommerceValidationError("Field path must contain dot-separated alphanumeric, underscore, or hyphen segments.");
15
+ }
16
+ return value;
17
+ }
@@ -1,4 +1,4 @@
1
- import { eq, and, inArray, type SQL } from "drizzle-orm";
1
+ import { eq, and, asc, count, desc, gt, inArray, isNull, lt, max, ne, or, type SQL } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import { CommerceNotFoundError } from "../../../kernel/errors.js";
4
4
  import type {
@@ -9,15 +9,21 @@ import {
9
9
  sellableEntities,
10
10
  sellableAttributes,
11
11
  sellableCustomFields,
12
+ entityFieldDefinitions,
12
13
  categories,
13
14
  entityCategories,
14
15
  brands,
15
16
  entityBrands,
17
+ entityTags,
16
18
  optionTypes,
17
19
  optionValues,
18
20
  variants,
19
21
  variantOptionValues,
22
+ sellableEntityRevisions,
23
+ catalogFieldOwnership,
24
+ type SellableEntityRevisionSnapshot,
20
25
  } from "../schema.js";
26
+ import { entityMedia } from "../../media/schema.js";
21
27
 
22
28
  // Infer types from Drizzle schema
23
29
  export type SellableEntity = typeof sellableEntities.$inferSelect;
@@ -27,6 +33,8 @@ export type SellableAttributeInsert = typeof sellableAttributes.$inferInsert;
27
33
  export type SellableCustomField = typeof sellableCustomFields.$inferSelect;
28
34
  export type SellableCustomFieldInsert =
29
35
  typeof sellableCustomFields.$inferInsert;
36
+ export type EntityFieldDefinitionRecord = typeof entityFieldDefinitions.$inferSelect;
37
+ export type EntityFieldDefinitionInsert = typeof entityFieldDefinitions.$inferInsert;
30
38
  export type Category = typeof categories.$inferSelect;
31
39
  export type CategoryInsert = typeof categories.$inferInsert;
32
40
  export type EntityCategory = typeof entityCategories.$inferSelect;
@@ -43,6 +51,14 @@ export type Variant = typeof variants.$inferSelect;
43
51
  export type VariantInsert = typeof variants.$inferInsert;
44
52
  export type VariantOptionValue = typeof variantOptionValues.$inferSelect;
45
53
  export type VariantOptionValueInsert = typeof variantOptionValues.$inferInsert;
54
+ export type SellableEntityRevision = typeof sellableEntityRevisions.$inferSelect;
55
+ export type SellableEntityRevisionInsert = typeof sellableEntityRevisions.$inferInsert;
56
+ export type CatalogFieldOwnership = typeof catalogFieldOwnership.$inferSelect;
57
+ export type CatalogFieldOwnershipInsert = typeof catalogFieldOwnership.$inferInsert;
58
+ export type ProposedCustomField = SellableCustomField & {
59
+ entitySlug: string;
60
+ entityType: string;
61
+ };
46
62
 
47
63
  /**
48
64
  * CatalogRepository provides type-safe database operations for catalog entities.
@@ -165,6 +181,247 @@ export class CatalogRepository {
165
181
  return result.length > 0;
166
182
  }
167
183
 
184
+ async findFieldOwnership(
185
+ entityId: string,
186
+ ctx?: TxContext,
187
+ storeId?: string,
188
+ ): Promise<CatalogFieldOwnership[]> {
189
+ const db = this.getDb(ctx);
190
+ const conditions = [eq(catalogFieldOwnership.entityId, entityId)];
191
+ if (storeId !== undefined) {
192
+ conditions.push(or(isNull(catalogFieldOwnership.storeId), eq(catalogFieldOwnership.storeId, storeId))!);
193
+ }
194
+ return db
195
+ .select()
196
+ .from(catalogFieldOwnership)
197
+ .where(and(...conditions))
198
+ .orderBy(asc(catalogFieldOwnership.fieldPath), asc(catalogFieldOwnership.updatedAt));
199
+ }
200
+
201
+ async findFieldOwnershipForResolution(
202
+ entityId: string,
203
+ storeId: string,
204
+ variantId: string | null,
205
+ ctx?: TxContext,
206
+ ): Promise<CatalogFieldOwnership[]> {
207
+ const db = this.getDb(ctx);
208
+ return db
209
+ .select()
210
+ .from(catalogFieldOwnership)
211
+ .where(and(
212
+ eq(catalogFieldOwnership.entityId, entityId),
213
+ or(isNull(catalogFieldOwnership.storeId), eq(catalogFieldOwnership.storeId, storeId)),
214
+ variantId == null
215
+ ? isNull(catalogFieldOwnership.variantId)
216
+ : or(isNull(catalogFieldOwnership.variantId), eq(catalogFieldOwnership.variantId, variantId)),
217
+ ));
218
+ }
219
+
220
+ async upsertFieldOwnership(
221
+ data: CatalogFieldOwnershipInsert,
222
+ ctx?: TxContext,
223
+ ): Promise<CatalogFieldOwnership> {
224
+ const db = this.getDb(ctx);
225
+ const rows = await db
226
+ .insert(catalogFieldOwnership)
227
+ .values(data)
228
+ .onConflictDoUpdate({
229
+ target: [
230
+ catalogFieldOwnership.organizationId,
231
+ catalogFieldOwnership.entityId,
232
+ catalogFieldOwnership.variantId,
233
+ catalogFieldOwnership.storeId,
234
+ catalogFieldOwnership.fieldPath,
235
+ ],
236
+ set: {
237
+ owner: data.owner,
238
+ updatedAt: data.updatedAt ?? new Date(),
239
+ },
240
+ })
241
+ .returning();
242
+ return rows[0]!;
243
+ }
244
+
245
+ async seedFieldOwnership(
246
+ data: CatalogFieldOwnershipInsert[],
247
+ ctx?: TxContext,
248
+ ): Promise<void> {
249
+ if (data.length === 0) return;
250
+ const db = this.getDb(ctx);
251
+ await db.insert(catalogFieldOwnership).values(data).onConflictDoNothing();
252
+ }
253
+
254
+ async findRevisionsByEntityId(
255
+ entityId: string,
256
+ ctx?: TxContext,
257
+ ): Promise<SellableEntityRevision[]> {
258
+ const db = this.getDb(ctx);
259
+ return db
260
+ .select()
261
+ .from(sellableEntityRevisions)
262
+ .where(eq(sellableEntityRevisions.entityId, entityId))
263
+ .orderBy(asc(sellableEntityRevisions.revision));
264
+ }
265
+
266
+ async findRevisionMarkers(
267
+ entityId: string,
268
+ since?: Date,
269
+ ctx?: TxContext,
270
+ ): Promise<Array<Pick<SellableEntityRevision, "createdAt" | "reason">>> {
271
+ const db = this.getDb(ctx);
272
+ return db
273
+ .select({
274
+ createdAt: sellableEntityRevisions.createdAt,
275
+ reason: sellableEntityRevisions.reason,
276
+ })
277
+ .from(sellableEntityRevisions)
278
+ .where(and(
279
+ eq(sellableEntityRevisions.entityId, entityId),
280
+ ...(since ? [gt(sellableEntityRevisions.createdAt, since)] : []),
281
+ ))
282
+ .orderBy(asc(sellableEntityRevisions.createdAt));
283
+ }
284
+
285
+ async findLatestRevision(
286
+ entityId: string,
287
+ ctx?: TxContext,
288
+ ): Promise<SellableEntityRevision | undefined> {
289
+ const db = this.getDb(ctx);
290
+ const rows = await db
291
+ .select()
292
+ .from(sellableEntityRevisions)
293
+ .where(eq(sellableEntityRevisions.entityId, entityId))
294
+ .orderBy(desc(sellableEntityRevisions.revision))
295
+ .limit(1);
296
+ return rows[0];
297
+ }
298
+
299
+ async findRevisionById(
300
+ entityId: string,
301
+ id: string,
302
+ ctx?: TxContext,
303
+ ): Promise<SellableEntityRevision | undefined> {
304
+ const db = this.getDb(ctx);
305
+ const rows = await db
306
+ .select()
307
+ .from(sellableEntityRevisions)
308
+ .where(
309
+ and(
310
+ eq(sellableEntityRevisions.entityId, entityId),
311
+ eq(sellableEntityRevisions.id, id),
312
+ ),
313
+ );
314
+ return rows[0];
315
+ }
316
+
317
+ async createRevision(
318
+ data: Omit<SellableEntityRevisionInsert, "id" | "revision" | "pinned">,
319
+ ctx?: TxContext,
320
+ ): Promise<SellableEntityRevision> {
321
+ const db = this.getDb(ctx);
322
+ const rows = await db
323
+ .select({ revision: max(sellableEntityRevisions.revision) })
324
+ .from(sellableEntityRevisions)
325
+ .where(eq(sellableEntityRevisions.entityId, data.entityId));
326
+ const revision = (rows[0]?.revision ?? 0) + 1;
327
+ const inserted = await db
328
+ .insert(sellableEntityRevisions)
329
+ .values({
330
+ ...data,
331
+ revision,
332
+ pinned: revision === 1,
333
+ })
334
+ .returning();
335
+ return inserted[0]!;
336
+ }
337
+
338
+ async updateRevision(
339
+ id: string,
340
+ data: Partial<Pick<SellableEntityRevisionInsert, "createdAt" | "pinned">>,
341
+ ctx?: TxContext,
342
+ ): Promise<SellableEntityRevision | undefined> {
343
+ const db = this.getDb(ctx);
344
+ const rows = await db
345
+ .update(sellableEntityRevisions)
346
+ .set(data)
347
+ .where(eq(sellableEntityRevisions.id, id))
348
+ .returning();
349
+ return rows[0];
350
+ }
351
+
352
+ async deleteRevisionsOlderThan(
353
+ organizationId: string,
354
+ cutoff: Date,
355
+ ctx?: TxContext,
356
+ ): Promise<number> {
357
+ const db = this.getDb(ctx);
358
+ const deleted = await db
359
+ .delete(sellableEntityRevisions)
360
+ .where(
361
+ and(
362
+ eq(sellableEntityRevisions.organizationId, organizationId),
363
+ lt(sellableEntityRevisions.createdAt, cutoff),
364
+ eq(sellableEntityRevisions.pinned, false),
365
+ gt(sellableEntityRevisions.revision, 1),
366
+ ),
367
+ )
368
+ .returning({ id: sellableEntityRevisions.id });
369
+ return deleted.length;
370
+ }
371
+
372
+ async snapshotEntity(
373
+ entityId: string,
374
+ ctx?: TxContext,
375
+ ): Promise<SellableEntityRevisionSnapshot> {
376
+ const entity = await this.findEntityById(entityId, ctx);
377
+ if (!entity) throw new CommerceNotFoundError("Entity not found.");
378
+ const [attributes, customFields, categoriesForEntity, brandsForEntity, media, tagsForEntity] = await Promise.all([
379
+ this.findAttributesByEntityId(entityId, ctx),
380
+ this.findAllCustomFieldsByEntityId(entityId, ctx),
381
+ this.findEntityCategories(entityId, ctx),
382
+ this.findEntityBrands(entityId, ctx),
383
+ this.findEntityMedia(entityId, ctx),
384
+ this.findEntityTags(entityId, ctx),
385
+ ]);
386
+ return {
387
+ entity: entity as unknown as Record<string, unknown>,
388
+ attributes: attributes.sort((a, b) => a.locale.localeCompare(b.locale) || a.id.localeCompare(b.id)) as unknown as Array<Record<string, unknown>>,
389
+ customFields: customFields.sort((a, b) => a.locale.localeCompare(b.locale) || a.fieldName.localeCompare(b.fieldName) || a.id.localeCompare(b.id)) as unknown as Array<Record<string, unknown>>,
390
+ media: media.sort((a, b) => a.mediaAssetId.localeCompare(b.mediaAssetId) || (a.variantId ?? "").localeCompare(b.variantId ?? "")) as unknown as Array<Record<string, unknown>>,
391
+ categories: categoriesForEntity.sort((a, b) => a.categoryId.localeCompare(b.categoryId)) as unknown as Array<Record<string, unknown>>,
392
+ brands: brandsForEntity.sort((a, b) => a.brandId.localeCompare(b.brandId)) as unknown as Array<Record<string, unknown>>,
393
+ tags: tagsForEntity.sort((a, b) => a.tagId.localeCompare(b.tagId)) as unknown as Array<Record<string, unknown>>,
394
+ };
395
+ }
396
+
397
+ async findEntityMedia(
398
+ entityId: string,
399
+ ctx?: TxContext,
400
+ ): Promise<Array<typeof entityMedia.$inferSelect>> {
401
+ const db = this.getDb(ctx);
402
+ return db
403
+ .select()
404
+ .from(entityMedia)
405
+ .where(eq(entityMedia.entityId, entityId));
406
+ }
407
+
408
+ async deleteEntityMediaByEntityId(
409
+ entityId: string,
410
+ ctx?: TxContext,
411
+ ): Promise<void> {
412
+ const db = this.getDb(ctx);
413
+ await db.delete(entityMedia).where(eq(entityMedia.entityId, entityId));
414
+ }
415
+
416
+ async createEntityMedia(
417
+ data: typeof entityMedia.$inferInsert,
418
+ ctx?: TxContext,
419
+ ): Promise<typeof entityMedia.$inferSelect> {
420
+ const db = this.getDb(ctx);
421
+ const rows = await db.insert(entityMedia).values(data).returning();
422
+ return rows[0]!;
423
+ }
424
+
168
425
  // ─────────────────────────────────────────────────────────────────────────────
169
426
  // Sellable Attributes
170
427
  // ─────────────────────────────────────────────────────────────────────────────
@@ -252,6 +509,22 @@ export class CatalogRepository {
252
509
  async findCustomFieldsByEntityId(
253
510
  entityId: string,
254
511
  ctx?: TxContext,
512
+ ): Promise<SellableCustomField[]> {
513
+ const db = this.getDb(ctx);
514
+ return db
515
+ .select()
516
+ .from(sellableCustomFields)
517
+ .where(
518
+ and(
519
+ eq(sellableCustomFields.entityId, entityId),
520
+ eq(sellableCustomFields.status, "approved"),
521
+ ),
522
+ );
523
+ }
524
+
525
+ async findAllCustomFieldsByEntityId(
526
+ entityId: string,
527
+ ctx?: TxContext,
255
528
  ): Promise<SellableCustomField[]> {
256
529
  const db = this.getDb(ctx);
257
530
  return db
@@ -260,6 +533,137 @@ export class CatalogRepository {
260
533
  .where(eq(sellableCustomFields.entityId, entityId));
261
534
  }
262
535
 
536
+ async findProposedCustomField(
537
+ entityId: string,
538
+ fieldName: string,
539
+ locale = "en",
540
+ ctx?: TxContext,
541
+ ): Promise<SellableCustomField | undefined> {
542
+ const db = this.getDb(ctx);
543
+ const rows = await db
544
+ .select()
545
+ .from(sellableCustomFields)
546
+ .where(
547
+ and(
548
+ eq(sellableCustomFields.entityId, entityId),
549
+ eq(sellableCustomFields.fieldName, fieldName),
550
+ eq(sellableCustomFields.locale, locale),
551
+ eq(sellableCustomFields.status, "proposed"),
552
+ ),
553
+ )
554
+ .orderBy(desc(sellableCustomFields.createdAt), desc(sellableCustomFields.id))
555
+ .limit(1);
556
+ return rows[0];
557
+ }
558
+
559
+ async updateProposedCustomField(
560
+ id: string,
561
+ data: Partial<Omit<SellableCustomFieldInsert, "id">>,
562
+ ctx?: TxContext,
563
+ ): Promise<SellableCustomField | undefined> {
564
+ const db = this.getDb(ctx);
565
+ const rows = await db
566
+ .update(sellableCustomFields)
567
+ .set({ ...data, updatedAt: new Date() })
568
+ .where(
569
+ and(
570
+ eq(sellableCustomFields.id, id),
571
+ eq(sellableCustomFields.status, "proposed"),
572
+ ),
573
+ )
574
+ .returning();
575
+ return rows[0];
576
+ }
577
+
578
+ async rejectOtherProposedCustomFields(
579
+ entityId: string,
580
+ fieldName: string,
581
+ locale: string,
582
+ excludeId: string,
583
+ ctx?: TxContext,
584
+ ): Promise<number> {
585
+ const db = this.getDb(ctx);
586
+ const rows = await db
587
+ .update(sellableCustomFields)
588
+ .set({ status: "rejected", updatedAt: new Date() })
589
+ .where(
590
+ and(
591
+ eq(sellableCustomFields.entityId, entityId),
592
+ eq(sellableCustomFields.fieldName, fieldName),
593
+ eq(sellableCustomFields.locale, locale),
594
+ eq(sellableCustomFields.status, "proposed"),
595
+ ne(sellableCustomFields.id, excludeId),
596
+ ),
597
+ )
598
+ .returning({ id: sellableCustomFields.id });
599
+ return rows.length;
600
+ }
601
+
602
+ async listProposedCustomFields(
603
+ organizationId: string,
604
+ pagination: { page: number; limit: number },
605
+ filter?: { entityType?: string },
606
+ ctx?: TxContext,
607
+ ): Promise<{ items: ProposedCustomField[]; total: number }> {
608
+ const db = this.getDb(ctx);
609
+ const page = Math.max(1, pagination.page);
610
+ const limit = Math.max(1, pagination.limit);
611
+ const conditions: SQL[] = [
612
+ eq(sellableCustomFields.status, "proposed"),
613
+ eq(sellableEntities.organizationId, organizationId),
614
+ ];
615
+ if (filter?.entityType !== undefined) {
616
+ conditions.push(eq(sellableEntities.type, filter.entityType));
617
+ }
618
+ const where = and(...conditions);
619
+ const rows = await db
620
+ .select({
621
+ customField: sellableCustomFields,
622
+ entitySlug: sellableEntities.slug,
623
+ entityType: sellableEntities.type,
624
+ })
625
+ .from(sellableCustomFields)
626
+ .innerJoin(sellableEntities, eq(sellableCustomFields.entityId, sellableEntities.id))
627
+ .where(where)
628
+ .orderBy(asc(sellableCustomFields.createdAt), asc(sellableCustomFields.id))
629
+ .limit(limit)
630
+ .offset((page - 1) * limit);
631
+ const totalRows = await db
632
+ .select({ count: count() })
633
+ .from(sellableCustomFields)
634
+ .innerJoin(sellableEntities, eq(sellableCustomFields.entityId, sellableEntities.id))
635
+ .where(where);
636
+ return {
637
+ items: rows.map(({ customField, entitySlug, entityType }) => ({
638
+ ...customField,
639
+ entitySlug,
640
+ entityType,
641
+ })),
642
+ total: totalRows[0]?.count ?? 0,
643
+ };
644
+ }
645
+
646
+ async findCustomFieldByName(
647
+ entityId: string,
648
+ fieldName: string,
649
+ locale = "en",
650
+ ctx?: TxContext,
651
+ ): Promise<SellableCustomField | undefined> {
652
+ const db = this.getDb(ctx);
653
+ const rows = await db
654
+ .select()
655
+ .from(sellableCustomFields)
656
+ .where(
657
+ and(
658
+ eq(sellableCustomFields.entityId, entityId),
659
+ eq(sellableCustomFields.fieldName, fieldName),
660
+ eq(sellableCustomFields.locale, locale),
661
+ eq(sellableCustomFields.status, "approved"),
662
+ ),
663
+ );
664
+ return rows[0];
665
+ }
666
+
263
667
  async createCustomField(
264
668
  data: SellableCustomFieldInsert,
265
669
  ctx?: TxContext,
@@ -269,6 +673,54 @@ export class CatalogRepository {
269
673
  return rows[0]!;
270
674
  }
271
675
 
676
+ async updateCustomField(
677
+ id: string,
678
+ data: Partial<Omit<SellableCustomFieldInsert, "id">>,
679
+ ctx?: TxContext,
680
+ ): Promise<SellableCustomField | undefined> {
681
+ const db = this.getDb(ctx);
682
+ const rows = await db
683
+ .update(sellableCustomFields)
684
+ .set({ ...data, updatedAt: new Date() })
685
+ .where(eq(sellableCustomFields.id, id))
686
+ .returning();
687
+ return rows[0];
688
+ }
689
+
690
+ async upsertCustomField(
691
+ entityId: string,
692
+ fieldName: string,
693
+ locale: string,
694
+ data: Omit<SellableCustomFieldInsert, "entityId" | "fieldName">,
695
+ ctx?: TxContext,
696
+ ): Promise<SellableCustomField> {
697
+ const existing = await this.findCustomFieldByName(entityId, fieldName, locale, ctx);
698
+ if (existing) {
699
+ const updated = await this.updateCustomField(existing.id, data, ctx);
700
+ return updated!;
701
+ }
702
+ return this.createCustomField({ ...data, entityId, fieldName, locale }, ctx);
703
+ }
704
+
705
+ async deleteCustomField(
706
+ entityId: string,
707
+ fieldName: string,
708
+ locale = "en",
709
+ ctx?: TxContext,
710
+ ): Promise<void> {
711
+ const db = this.getDb(ctx);
712
+ await db
713
+ .delete(sellableCustomFields)
714
+ .where(
715
+ and(
716
+ eq(sellableCustomFields.entityId, entityId),
717
+ eq(sellableCustomFields.fieldName, fieldName),
718
+ eq(sellableCustomFields.locale, locale),
719
+ eq(sellableCustomFields.status, "approved"),
720
+ ),
721
+ );
722
+ }
723
+
272
724
  async deleteCustomFieldsByEntityId(
273
725
  entityId: string,
274
726
  ctx?: TxContext,
@@ -279,6 +731,79 @@ export class CatalogRepository {
279
731
  .where(eq(sellableCustomFields.entityId, entityId));
280
732
  }
281
733
 
734
+ async findEntityFieldDefinitions(
735
+ organizationId: string,
736
+ entityType?: string,
737
+ ctx?: TxContext,
738
+ ): Promise<EntityFieldDefinitionRecord[]> {
739
+ const db = this.getDb(ctx);
740
+ const conditions = [eq(entityFieldDefinitions.organizationId, organizationId)];
741
+ if (entityType !== undefined) {
742
+ conditions.push(eq(entityFieldDefinitions.entityType, entityType));
743
+ }
744
+ return db
745
+ .select()
746
+ .from(entityFieldDefinitions)
747
+ .where(and(...conditions))
748
+ .orderBy(asc(entityFieldDefinitions.entityType), asc(entityFieldDefinitions.sortOrder), asc(entityFieldDefinitions.name));
749
+ }
750
+
751
+ async findActiveEntityFieldDefinitions(
752
+ organizationId: string,
753
+ entityType: string,
754
+ ctx?: TxContext,
755
+ ): Promise<EntityFieldDefinitionRecord[]> {
756
+ const db = this.getDb(ctx);
757
+ return db
758
+ .select()
759
+ .from(entityFieldDefinitions)
760
+ .where(
761
+ and(
762
+ eq(entityFieldDefinitions.organizationId, organizationId),
763
+ eq(entityFieldDefinitions.entityType, entityType),
764
+ eq(entityFieldDefinitions.status, "active"),
765
+ ),
766
+ )
767
+ .orderBy(asc(entityFieldDefinitions.sortOrder), asc(entityFieldDefinitions.name));
768
+ }
769
+
770
+ async findEntityFieldDefinitionById(
771
+ organizationId: string,
772
+ id: string,
773
+ ctx?: TxContext,
774
+ ): Promise<EntityFieldDefinitionRecord | undefined> {
775
+ const db = this.getDb(ctx);
776
+ const rows = await db
777
+ .select()
778
+ .from(entityFieldDefinitions)
779
+ .where(and(eq(entityFieldDefinitions.organizationId, organizationId), eq(entityFieldDefinitions.id, id)));
780
+ return rows[0];
781
+ }
782
+
783
+ async createEntityFieldDefinition(
784
+ data: EntityFieldDefinitionInsert,
785
+ ctx?: TxContext,
786
+ ): Promise<EntityFieldDefinitionRecord> {
787
+ const db = this.getDb(ctx);
788
+ const rows = await db.insert(entityFieldDefinitions).values(data).returning();
789
+ return rows[0]!;
790
+ }
791
+
792
+ async updateEntityFieldDefinition(
793
+ organizationId: string,
794
+ id: string,
795
+ data: Partial<Omit<EntityFieldDefinitionInsert, "id" | "organizationId">>,
796
+ ctx?: TxContext,
797
+ ): Promise<EntityFieldDefinitionRecord | undefined> {
798
+ const db = this.getDb(ctx);
799
+ const rows = await db
800
+ .update(entityFieldDefinitions)
801
+ .set({ ...data, updatedAt: new Date() })
802
+ .where(and(eq(entityFieldDefinitions.organizationId, organizationId), eq(entityFieldDefinitions.id, id)))
803
+ .returning();
804
+ return rows[0];
805
+ }
806
+
282
807
  // ─────────────────────────────────────────────────────────────────────────────
283
808
  // Categories
284
809
  // ─────────────────────────────────────────────────────────────────────────────
@@ -511,6 +1036,27 @@ export class CatalogRepository {
511
1036
  .where(eq(entityBrands.entityId, entityId));
512
1037
  }
513
1038
 
1039
+ async findEntityTags(
1040
+ entityId: string,
1041
+ ctx?: TxContext,
1042
+ ): Promise<Array<typeof entityTags.$inferSelect>> {
1043
+ const db = this.getDb(ctx);
1044
+ return db
1045
+ .select()
1046
+ .from(entityTags)
1047
+ .where(eq(entityTags.entityId, entityId));
1048
+ }
1049
+
1050
+ async addEntityTag(entityId: string, tagId: string, ctx?: TxContext): Promise<void> {
1051
+ const db = this.getDb(ctx);
1052
+ await db.insert(entityTags).values({ entityId, tagId }).onConflictDoNothing();
1053
+ }
1054
+
1055
+ async deleteEntityTagsByEntityId(entityId: string, ctx?: TxContext): Promise<void> {
1056
+ const db = this.getDb(ctx);
1057
+ await db.delete(entityTags).where(eq(entityTags.entityId, entityId));
1058
+ }
1059
+
514
1060
  async addEntityToBrand(
515
1061
  entityId: string,
516
1062
  brandId: string,