@porulle/core 0.1.0 → 0.5.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 (165) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +17 -1
  3. package/dist/config/define-config.d.ts.map +1 -1
  4. package/dist/config/define-config.js +6 -0
  5. package/dist/config/types.d.ts +12 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/hooks/checkout.d.ts +3 -3
  8. package/dist/hooks/checkout.js +4 -4
  9. package/dist/index.d.ts +9 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
  13. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
  14. package/dist/interfaces/rest/audit-middleware.js +82 -0
  15. package/dist/interfaces/rest/parse-json.d.ts +39 -0
  16. package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
  17. package/dist/interfaces/rest/parse-json.js +45 -0
  18. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  19. package/dist/interfaces/rest/routes/catalog.js +17 -2
  20. package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
  21. package/dist/interfaces/rest/routes/customers.js +59 -4
  22. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  23. package/dist/interfaces/rest/routes/inventory.js +4 -2
  24. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  25. package/dist/interfaces/rest/routes/media.js +6 -0
  26. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  27. package/dist/interfaces/rest/routes/orders.js +23 -1
  28. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  29. package/dist/interfaces/rest/routes/promotions.js +21 -1
  30. package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
  31. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  32. package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
  33. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  34. package/dist/interfaces/rest/schemas/catalog.js +28 -1
  35. package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
  36. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  37. package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
  38. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  39. package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
  40. package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
  41. package/dist/interfaces/rest/schemas/customers.js +88 -0
  42. package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
  43. package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
  44. package/dist/interfaces/rest/schemas/media.d.ts +105 -0
  45. package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
  46. package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
  47. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  48. package/dist/interfaces/rest/schemas/orders.js +20 -0
  49. package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
  50. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  51. package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
  52. package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
  53. package/dist/interfaces/rest/schemas/promotions.js +25 -1
  54. package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
  55. package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
  56. package/dist/interfaces/rest/schemas/shared.js +8 -0
  57. package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
  58. package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
  59. package/dist/kernel/database/adapter.d.ts +6 -0
  60. package/dist/kernel/database/adapter.d.ts.map +1 -1
  61. package/dist/kernel/database/adapter.js +62 -1
  62. package/dist/kernel/database/migrate.d.ts +22 -2
  63. package/dist/kernel/database/migrate.d.ts.map +1 -1
  64. package/dist/kernel/database/migrate.js +42 -2
  65. package/dist/modules/analytics/hooks.d.ts +5 -5
  66. package/dist/modules/analytics/hooks.js +5 -5
  67. package/dist/modules/analytics/repository/index.d.ts.map +1 -1
  68. package/dist/modules/catalog/category-service.d.ts +6 -1
  69. package/dist/modules/catalog/category-service.d.ts.map +1 -1
  70. package/dist/modules/catalog/category-service.js +35 -5
  71. package/dist/modules/catalog/schema.d.ts +17 -0
  72. package/dist/modules/catalog/schema.d.ts.map +1 -1
  73. package/dist/modules/catalog/schema.js +2 -0
  74. package/dist/modules/catalog/service.d.ts +11 -2
  75. package/dist/modules/catalog/service.d.ts.map +1 -1
  76. package/dist/modules/catalog/service.js +8 -2
  77. package/dist/modules/customers/repository/index.d.ts +8 -1
  78. package/dist/modules/customers/repository/index.d.ts.map +1 -1
  79. package/dist/modules/customers/repository/index.js +42 -2
  80. package/dist/modules/customers/schema.d.ts +162 -0
  81. package/dist/modules/customers/schema.d.ts.map +1 -1
  82. package/dist/modules/customers/schema.js +18 -0
  83. package/dist/modules/customers/service.d.ts +32 -2
  84. package/dist/modules/customers/service.d.ts.map +1 -1
  85. package/dist/modules/customers/service.js +119 -3
  86. package/dist/modules/inventory/schemas.d.ts +7 -1
  87. package/dist/modules/inventory/schemas.d.ts.map +1 -1
  88. package/dist/modules/inventory/schemas.js +6 -1
  89. package/dist/modules/inventory/service.d.ts +18 -0
  90. package/dist/modules/inventory/service.d.ts.map +1 -1
  91. package/dist/modules/inventory/service.js +58 -21
  92. package/dist/modules/media/noop-adapter.d.ts +11 -0
  93. package/dist/modules/media/noop-adapter.d.ts.map +1 -0
  94. package/dist/modules/media/noop-adapter.js +31 -0
  95. package/dist/modules/orders/repository/index.d.ts +19 -0
  96. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  97. package/dist/modules/orders/repository/index.js +42 -1
  98. package/dist/modules/orders/service.d.ts +31 -0
  99. package/dist/modules/orders/service.d.ts.map +1 -1
  100. package/dist/modules/orders/service.js +52 -2
  101. package/dist/modules/payments/repository/index.d.ts.map +1 -1
  102. package/dist/modules/promotions/schemas.d.ts +46 -0
  103. package/dist/modules/promotions/schemas.d.ts.map +1 -1
  104. package/dist/modules/promotions/schemas.js +19 -8
  105. package/dist/modules/promotions/service.d.ts +8 -2
  106. package/dist/modules/promotions/service.d.ts.map +1 -1
  107. package/dist/modules/promotions/service.js +74 -9
  108. package/dist/modules/search/repository/index.d.ts.map +1 -1
  109. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  110. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  111. package/dist/runtime/client-ip.d.ts +31 -0
  112. package/dist/runtime/client-ip.d.ts.map +1 -0
  113. package/dist/runtime/client-ip.js +31 -0
  114. package/dist/runtime/server.d.ts +4 -0
  115. package/dist/runtime/server.d.ts.map +1 -1
  116. package/dist/runtime/server.js +35 -19
  117. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  118. package/dist/test-utils/create-plugin-test-app.js +4 -1
  119. package/dist/test-utils/test-actors.d.ts +3 -2
  120. package/dist/test-utils/test-actors.d.ts.map +1 -1
  121. package/dist/test-utils/test-actors.js +3 -2
  122. package/package.json +4 -4
  123. package/src/auth/middleware.ts +18 -1
  124. package/src/config/define-config.ts +7 -0
  125. package/src/config/types.ts +12 -0
  126. package/src/hooks/checkout.ts +4 -4
  127. package/src/index.ts +18 -1
  128. package/src/interfaces/rest/audit-middleware.ts +110 -0
  129. package/src/interfaces/rest/parse-json.ts +81 -0
  130. package/src/interfaces/rest/routes/catalog.ts +21 -1
  131. package/src/interfaces/rest/routes/customers.ts +64 -3
  132. package/src/interfaces/rest/routes/inventory.ts +4 -2
  133. package/src/interfaces/rest/routes/media.ts +7 -0
  134. package/src/interfaces/rest/routes/orders.ts +21 -1
  135. package/src/interfaces/rest/routes/promotions.ts +22 -1
  136. package/src/interfaces/rest/schemas/catalog.ts +30 -1
  137. package/src/interfaces/rest/schemas/customers.ts +99 -0
  138. package/src/interfaces/rest/schemas/orders.ts +21 -0
  139. package/src/interfaces/rest/schemas/promotions.ts +32 -1
  140. package/src/interfaces/rest/schemas/shared.ts +9 -0
  141. package/src/kernel/database/adapter.ts +64 -1
  142. package/src/kernel/database/migrate.ts +50 -2
  143. package/src/modules/analytics/hooks.ts +5 -5
  144. package/src/modules/analytics/repository/index.ts +0 -1
  145. package/src/modules/catalog/category-service.ts +26 -5
  146. package/src/modules/catalog/schema.ts +2 -0
  147. package/src/modules/catalog/service.ts +25 -3
  148. package/src/modules/customers/repository/index.ts +55 -1
  149. package/src/modules/customers/schema.ts +19 -0
  150. package/src/modules/customers/service.ts +137 -2
  151. package/src/modules/inventory/schemas.ts +6 -1
  152. package/src/modules/inventory/service.ts +84 -29
  153. package/src/modules/media/noop-adapter.ts +36 -0
  154. package/src/modules/orders/repository/index.ts +63 -1
  155. package/src/modules/orders/service.ts +86 -3
  156. package/src/modules/payments/repository/index.ts +0 -1
  157. package/src/modules/promotions/schemas.ts +21 -4
  158. package/src/modules/promotions/service.ts +90 -18
  159. package/src/modules/search/repository/index.ts +0 -1
  160. package/src/modules/shipping/repository/index.ts +0 -1
  161. package/src/modules/tax/repository/index.ts +0 -1
  162. package/src/runtime/client-ip.ts +49 -0
  163. package/src/runtime/server.ts +39 -18
  164. package/src/test-utils/create-plugin-test-app.ts +3 -1
  165. package/src/test-utils/test-actors.ts +3 -2
@@ -10,17 +10,20 @@
10
10
  *
11
11
  * Usage in consumer's drizzle.config.ts:
12
12
  *
13
- * import { getSchema } from "@porulle/core";
13
+ * import { getSchemaFiles } from "@porulle/core";
14
14
  * import { defineConfig } from "drizzle-kit";
15
15
  *
16
16
  * export default defineConfig({
17
17
  * dialect: "postgresql",
18
- * schema: getSchema(),
18
+ * schema: getSchemaFiles(),
19
19
  * dbCredentials: { url: process.env.DATABASE_URL! },
20
20
  * });
21
21
  */
22
22
 
23
+ import { createRequire } from "node:module";
24
+ import { fileURLToPath } from "node:url";
23
25
  import type { CommerceConfig } from "../../config/types.js";
26
+ import { unwrapDb } from "./adapter.js";
24
27
  import * as schema from "./schema.js";
25
28
 
26
29
  /**
@@ -74,3 +77,48 @@ export function getTableNames(): string[] {
74
77
  )
75
78
  .map(([key]) => key);
76
79
  }
80
+
81
+ /**
82
+ * Returns the absolute filesystem path(s) of the combined schema module for
83
+ * use as drizzle-kit's `schema` option in a consumer's `drizzle.config.ts`.
84
+ *
85
+ * npm consumers don't have the raw `.ts` sources, so a relative path into
86
+ * `node_modules` is brittle. This resolves the installed schema module
87
+ * (compiled `schema.js` for published consumers, `schema.ts` under Bun) so
88
+ * `drizzle-kit push` / `generate` discover every core table.
89
+ */
90
+ export function getSchemaFiles(): string[] {
91
+ // Resolve the sibling schema module that actually exists: `.ts` when running
92
+ // from source (Bun export condition), `.js` for published/compiled consumers.
93
+ const ext = fileURLToPath(import.meta.url).endsWith(".ts") ? ".ts" : ".js";
94
+ return [fileURLToPath(new URL(`./schema${ext}`, import.meta.url))];
95
+ }
96
+
97
+ /**
98
+ * Programmatically creates the core tables in the target database
99
+ * (creates tables if they don't exist), without migration files.
100
+ *
101
+ * Uses `drizzle-kit/api` — drizzle-kit introspects the live database and
102
+ * generates the minimal DDL to converge it on the core schema. Pass a
103
+ * Drizzle instance bound to your database. `drizzle-kit` must be available
104
+ * (it is the standard schema tool); a clear error is thrown if it is not.
105
+ */
106
+ export async function pushSchema(drizzleInstance: unknown): Promise<void> {
107
+ const require = createRequire(import.meta.url);
108
+ let drizzleKit: {
109
+ pushSchema(
110
+ imports: Record<string, unknown>,
111
+ db: unknown,
112
+ ): Promise<{ apply: () => Promise<void> }>;
113
+ };
114
+ try {
115
+ drizzleKit = require("drizzle-kit/api");
116
+ } catch {
117
+ throw new Error(
118
+ "pushSchema() requires `drizzle-kit` to be installed. Add it to your project: bun add -d drizzle-kit",
119
+ );
120
+ }
121
+ // drizzle-kit needs the native driver result shape; unwrap a normalized db.
122
+ const { apply } = await drizzleKit.pushSchema(getSchema(), unwrapDb(drizzleInstance));
123
+ await apply();
124
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Analytics hooks — RFC-006
2
+ * Analytics hooks — legacy compatibility surface.
3
3
  *
4
4
  * The in-memory event recording hooks (recordOrderAnalyticsEvent,
5
5
  * recordInventoryAnalyticsEvent) have been removed. The source tables
6
- * (orders, inventory_levels, customers) ARE the source of truth.
7
- * The DrizzleAnalyticsAdapter queries them directly via SQL.
6
+ * (orders, inventory_levels, customers) ARE the source of truth; the
7
+ * DrizzleAnalyticsAdapter queries them directly via SQL.
8
8
  *
9
- * These empty exports preserve backwards compatibility for any code
10
- * that imports from this module.
9
+ * The empty exports below preserve backwards compatibility for code
10
+ * still importing from this module.
11
11
  */
@@ -1,6 +1,5 @@
1
1
  import type { TxContext } from "../../../kernel/database/tx-context.js";
2
2
 
3
3
  export interface AnalyticsRepository {
4
- // RFC-002 scaffold: module repositories become the only persistence boundary.
5
4
  ping(ctx: TxContext): Promise<void>;
6
5
  }
@@ -29,10 +29,31 @@ export class CategoryService {
29
29
  }
30
30
  }
31
31
 
32
- async listCategories(ctx?: TxContext): Promise<Result<CategorySummary[]>> {
32
+ async listCategories(ctx?: TxContext, opts?: { includeArchived?: boolean }): Promise<Result<CategorySummary[]>> {
33
33
  const allCategories = await this.repo.findAllCategories(resolveOrgId(ctx?.actor ?? null), ctx);
34
- const sorted = allCategories.sort((a, b) => a.sortOrder - b.sortOrder || a.slug.localeCompare(b.slug));
35
- return Ok(sorted.map((c) => ({ id: c.id, parentId: c.parentId, slug: c.slug, sortOrder: c.sortOrder, metadata: c.metadata ?? {} })));
34
+ const visible = opts?.includeArchived
35
+ ? allCategories
36
+ : allCategories.filter((c) => (c.status ?? "active") === "active");
37
+ const sorted = visible.sort((a, b) => a.sortOrder - b.sortOrder || a.slug.localeCompare(b.slug));
38
+ return Ok(sorted.map((c) => ({ id: c.id, parentId: c.parentId, slug: c.slug, sortOrder: c.sortOrder, status: c.status ?? "active", metadata: c.metadata ?? {} })));
39
+ }
40
+
41
+ private async setStatus(id: string, status: "active" | "archived", actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
42
+ try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
43
+ const existing = await this.repo.findCategoryById(id, ctx);
44
+ if (!existing) return Err(new CommerceNotFoundError("Category not found."));
45
+ try { this.assertSameOrg(existing, actor); } catch (error) { return Err(toCommerceError(error)); }
46
+ const updated = await this.repo.updateCategory(id, { status }, ctx);
47
+ if (!updated) return Err(new CommerceNotFoundError("Category not found."));
48
+ return Ok({ id: updated.id, parentId: updated.parentId, slug: updated.slug, sortOrder: updated.sortOrder, status: updated.status ?? status, metadata: updated.metadata ?? {} });
49
+ }
50
+
51
+ async archiveCategory(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
52
+ return this.setStatus(id, "archived", actor, ctx);
53
+ }
54
+
55
+ async restoreCategory(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
56
+ return this.setStatus(id, "active", actor, ctx);
36
57
  }
37
58
 
38
59
  async createCategory(input: CreateCategoryInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
@@ -45,7 +66,7 @@ export class CategoryService {
45
66
  const existingBySlug = await this.repo.findCategoryBySlug(orgId, input.slug, ctx);
46
67
  if (existingBySlug) return Err(new CommerceConflictError(`Category with slug ${input.slug} already exists.`));
47
68
  const category = await this.repo.createCategory({ organizationId: orgId, ...(input.id ? { id: input.id } : {}), slug: input.slug, sortOrder: input.sortOrder ?? 0, metadata: input.metadata ?? {}, ...(input.parentId !== undefined ? { parentId: input.parentId } : {}) }, ctx);
48
- return Ok({ id: category.id, parentId: category.parentId, slug: category.slug, sortOrder: category.sortOrder, metadata: category.metadata ?? {} });
69
+ return Ok({ id: category.id, parentId: category.parentId, slug: category.slug, sortOrder: category.sortOrder, status: category.status ?? "active", metadata: category.metadata ?? {} });
49
70
  }
50
71
 
51
72
  async updateCategory(id: string, input: UpdateCategoryInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
@@ -59,7 +80,7 @@ export class CategoryService {
59
80
  }
60
81
  const updated = await this.repo.updateCategory(id, { ...(input.slug !== undefined ? { slug: input.slug } : {}), ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), ...(input.metadata !== undefined ? { metadata: input.metadata } : {}), ...(input.parentId !== undefined ? { parentId: input.parentId } : {}) }, ctx);
61
82
  if (!updated) return Err(new CommerceNotFoundError("Category not found."));
62
- return Ok({ id: updated.id, parentId: updated.parentId, slug: updated.slug, sortOrder: updated.sortOrder, metadata: updated.metadata ?? {} });
83
+ return Ok({ id: updated.id, parentId: updated.parentId, slug: updated.slug, sortOrder: updated.sortOrder, status: updated.status ?? "active", metadata: updated.metadata ?? {} });
63
84
  }
64
85
 
65
86
  async deleteCategory(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
@@ -106,6 +106,8 @@ export const categories = pgTable(
106
106
  }),
107
107
  slug: text("slug").notNull(),
108
108
  sortOrder: integer("sort_order").notNull().default(0),
109
+ // active | archived. Soft-delete without cascading entity_categories.
110
+ status: text("status").notNull().default("active"),
109
111
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
110
112
  },
111
113
  (table) => ({
@@ -167,12 +167,25 @@ export interface CatalogService {
167
167
  locale: string,
168
168
  ctx?: TxContext,
169
169
  ): Promise<Result<SellableAttribute>>;
170
- listCategories(ctx?: TxContext): Promise<Result<CategorySummary[]>>;
170
+ listCategories(
171
+ ctx?: TxContext,
172
+ opts?: { includeArchived?: boolean },
173
+ ): Promise<Result<CategorySummary[]>>;
171
174
  createCategory(
172
175
  input: CreateCategoryInput,
173
176
  actor: Actor | null,
174
177
  ctx?: TxContext,
175
178
  ): Promise<Result<CategorySummary>>;
179
+ archiveCategory(
180
+ id: string,
181
+ actor: Actor | null,
182
+ ctx?: TxContext,
183
+ ): Promise<Result<CategorySummary>>;
184
+ restoreCategory(
185
+ id: string,
186
+ actor: Actor | null,
187
+ ctx?: TxContext,
188
+ ): Promise<Result<CategorySummary>>;
176
189
  updateCategory(
177
190
  id: string,
178
191
  input: UpdateCategoryInput,
@@ -266,6 +279,7 @@ export type CategorySummary = {
266
279
  parentId?: string | null;
267
280
  slug: string;
268
281
  sortOrder: number;
282
+ status: string;
269
283
  metadata: Record<string, unknown>;
270
284
  };
271
285
 
@@ -326,14 +340,22 @@ export class CatalogServiceImpl implements CatalogService {
326
340
  return this.entities.getAttributes(entityId, locale, ctx);
327
341
  }
328
342
 
329
- listCategories(ctx?: TxContext): Promise<Result<CategorySummary[]>> {
330
- return this.categories.listCategories(ctx);
343
+ listCategories(ctx?: TxContext, opts?: { includeArchived?: boolean }): Promise<Result<CategorySummary[]>> {
344
+ return this.categories.listCategories(ctx, opts);
331
345
  }
332
346
 
333
347
  createCategory(input: CreateCategoryInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
334
348
  return this.categories.createCategory(input, actor, ctx);
335
349
  }
336
350
 
351
+ archiveCategory(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
352
+ return this.categories.archiveCategory(id, actor, ctx);
353
+ }
354
+
355
+ restoreCategory(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
356
+ return this.categories.restoreCategory(id, actor, ctx);
357
+ }
358
+
337
359
  updateCategory(id: string, input: UpdateCategoryInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CategorySummary>> {
338
360
  return this.categories.updateCategory(id, input, actor, ctx);
339
361
  }
@@ -1,4 +1,4 @@
1
- import { eq, and } from "drizzle-orm";
1
+ import { eq, and, desc } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import type {
4
4
  DrizzleDatabase,
@@ -9,11 +9,14 @@ import {
9
9
  customerAddresses,
10
10
  customerGroups,
11
11
  customerGroupMembers,
12
+ customerInteractions,
12
13
  } from "../schema.js";
13
14
 
14
15
  // Infer types from Drizzle schema
15
16
  export type Customer = typeof customers.$inferSelect;
16
17
  export type CustomerInsert = typeof customers.$inferInsert;
18
+ export type CustomerInteraction = typeof customerInteractions.$inferSelect;
19
+ export type CustomerInteractionInsert = typeof customerInteractions.$inferInsert;
17
20
  export type CustomerAddress = typeof customerAddresses.$inferSelect;
18
21
  export type CustomerAddressInsert = typeof customerAddresses.$inferInsert;
19
22
  export type CustomerGroup = typeof customerGroups.$inferSelect;
@@ -101,6 +104,57 @@ export class CustomersRepository {
101
104
  return rows[0]!;
102
105
  }
103
106
 
107
+ // ─── Customer interactions ───────────────────────────────────────────────
108
+
109
+ async listInteractions(orgId: string, customerId: string, ctx?: TxContext): Promise<CustomerInteraction[]> {
110
+ const db = this.getDb(ctx);
111
+ return db
112
+ .select()
113
+ .from(customerInteractions)
114
+ .where(and(eq(customerInteractions.organizationId, orgId), eq(customerInteractions.customerId, customerId)))
115
+ .orderBy(desc(customerInteractions.at));
116
+ }
117
+
118
+ async findInteractionById(orgId: string, id: string, ctx?: TxContext): Promise<CustomerInteraction | undefined> {
119
+ const db = this.getDb(ctx);
120
+ const rows = await db
121
+ .select()
122
+ .from(customerInteractions)
123
+ .where(and(eq(customerInteractions.organizationId, orgId), eq(customerInteractions.id, id)))
124
+ .limit(1);
125
+ return rows[0];
126
+ }
127
+
128
+ async createInteraction(data: CustomerInteractionInsert, ctx?: TxContext): Promise<CustomerInteraction> {
129
+ const db = this.getDb(ctx);
130
+ const rows = await db.insert(customerInteractions).values(data).returning();
131
+ return rows[0]!;
132
+ }
133
+
134
+ async updateInteraction(
135
+ orgId: string,
136
+ id: string,
137
+ data: Partial<Omit<CustomerInteractionInsert, "id" | "organizationId" | "customerId">>,
138
+ ctx?: TxContext,
139
+ ): Promise<CustomerInteraction | undefined> {
140
+ const db = this.getDb(ctx);
141
+ const rows = await db
142
+ .update(customerInteractions)
143
+ .set(data)
144
+ .where(and(eq(customerInteractions.organizationId, orgId), eq(customerInteractions.id, id)))
145
+ .returning();
146
+ return rows[0];
147
+ }
148
+
149
+ async deleteInteraction(orgId: string, id: string, ctx?: TxContext): Promise<boolean> {
150
+ const db = this.getDb(ctx);
151
+ const rows = await db
152
+ .delete(customerInteractions)
153
+ .where(and(eq(customerInteractions.organizationId, orgId), eq(customerInteractions.id, id)))
154
+ .returning();
155
+ return rows.length > 0;
156
+ }
157
+
104
158
  async update(
105
159
  id: string,
106
160
  data: Partial<Omit<CustomerInsert, "id">>,
@@ -62,3 +62,22 @@ export const customerGroupMembers = pgTable("customer_group_members", {
62
62
  index("idx_group_members_group_id").on(table.groupId),
63
63
  uniqueIndex("customer_group_members_customer_group_unique").on(table.customerId, table.groupId),
64
64
  ]);
65
+
66
+ // Non-transactional clienteling interactions: visits, calls, inquiries,
67
+ // fittings, follow-ups, messages. Distinct from the audit log (accountability).
68
+ export const customerInteractions = pgTable("customer_interactions", {
69
+ id: uuid("id").defaultRandom().primaryKey(),
70
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
71
+ customerId: uuid("customer_id")
72
+ .references(() => customers.id, { onDelete: "cascade" })
73
+ .notNull(),
74
+ actorUserId: text("actor_user_id"),
75
+ kind: text("kind").notNull(),
76
+ notes: text("notes").notNull(),
77
+ relatedEntityId: uuid("related_entity_id"),
78
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
79
+ at: timestamp("at", { withTimezone: true }).defaultNow().notNull(),
80
+ }, (table) => [
81
+ index("idx_customer_interactions_customer_id").on(table.customerId),
82
+ index("idx_customer_interactions_org").on(table.organizationId),
83
+ ]);
@@ -1,6 +1,7 @@
1
1
  import { resolveOrgId } from "../../auth/org.js";
2
+ import { assertPermission } from "../../auth/permissions.js";
2
3
  import type { Actor } from "../../auth/types.js";
3
- import { CommerceNotFoundError } from "../../kernel/errors.js";
4
+ import { CommerceNotFoundError, toCommerceError } from "../../kernel/errors.js";
4
5
  import { runAfterHooks } from "../../kernel/hooks/executor.js";
5
6
  import { createHookContext } from "../../kernel/hooks/create-context.js";
6
7
  import type { HookRegistry } from "../../kernel/hooks/registry.js";
@@ -15,6 +16,7 @@ import type {
15
16
  Customer,
16
17
  CustomerAddress,
17
18
  CustomerAddressInsert,
19
+ CustomerInteraction,
18
20
  } from "./repository/index.js";
19
21
 
20
22
  interface CustomerServiceDeps {
@@ -47,6 +49,124 @@ export class CustomerService {
47
49
  this.repo = deps.repository;
48
50
  }
49
51
 
52
+ /**
53
+ * Create a customer. `userId` is optional: for walk-in / point-of-sale
54
+ * customers who never log in, omit it and a synthetic `anonymous_<uuid>` id
55
+ * is generated and the customer is flagged `metadata.walkIn = true`. This
56
+ * keeps non-account contacts out of the auth users table.
57
+ */
58
+ async createWalkIn(
59
+ input: {
60
+ userId?: string | undefined;
61
+ firstName?: string | undefined;
62
+ lastName?: string | undefined;
63
+ phone?: string | undefined;
64
+ email?: string | undefined;
65
+ metadata?: Record<string, unknown> | undefined;
66
+ },
67
+ actor?: Actor | null,
68
+ ctx?: TxContext,
69
+ ): Promise<Result<Customer>> {
70
+ try {
71
+ assertPermission(actor ?? null, "customers:create");
72
+ } catch (error) {
73
+ return Err(toCommerceError(error));
74
+ }
75
+
76
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
77
+ const isWalkIn = input.userId === undefined;
78
+ const userId = input.userId ?? `anonymous_${crypto.randomUUID()}`;
79
+ const metadata = {
80
+ ...(input.metadata ?? {}),
81
+ ...(isWalkIn ? { walkIn: true } : {}),
82
+ };
83
+
84
+ const customer = await this.repo.create(
85
+ {
86
+ organizationId: orgId,
87
+ userId,
88
+ metadata,
89
+ ...(input.firstName !== undefined ? { firstName: input.firstName } : {}),
90
+ ...(input.lastName !== undefined ? { lastName: input.lastName } : {}),
91
+ ...(input.phone !== undefined ? { phone: input.phone } : {}),
92
+ ...(input.email !== undefined ? { email: input.email } : {}),
93
+ },
94
+ ctx,
95
+ );
96
+
97
+ const afterHooks = this.deps.hooks.resolve(
98
+ "customers.afterCreate",
99
+ ) as AfterHook<Customer>[];
100
+ const hctx = hookContext(actor ?? null, this.deps.services, this.deps.database, ctx?.tx ?? null);
101
+ await runAfterHooks(afterHooks, null, customer, "create", hctx);
102
+
103
+ return Ok(customer);
104
+ }
105
+
106
+ // ─── Customer interactions (clienteling notes / visits / calls) ──────────
107
+
108
+ async listInteractions(customerId: string, actor?: Actor | null, ctx?: TxContext): Promise<Result<CustomerInteraction[]>> {
109
+ try { assertPermission(actor ?? null, "customers:read"); } catch (error) { return Err(toCommerceError(error)); }
110
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
111
+ return Ok(await this.repo.listInteractions(orgId, customerId, ctx));
112
+ }
113
+
114
+ async createInteraction(
115
+ customerId: string,
116
+ input: { kind: string; notes: string; relatedEntityId?: string | null | undefined; metadata?: Record<string, unknown> | undefined },
117
+ actor?: Actor | null,
118
+ ctx?: TxContext,
119
+ ): Promise<Result<CustomerInteraction>> {
120
+ try { assertPermission(actor ?? null, "customers:update"); } catch (error) { return Err(toCommerceError(error)); }
121
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
122
+ const customer = await this.repo.findById(orgId, customerId, ctx);
123
+ if (!customer) return Err(new CommerceNotFoundError("Customer not found."));
124
+ const interaction = await this.repo.createInteraction(
125
+ {
126
+ organizationId: orgId,
127
+ customerId,
128
+ kind: input.kind,
129
+ notes: input.notes,
130
+ actorUserId: actor?.userId ?? null,
131
+ relatedEntityId: input.relatedEntityId ?? null,
132
+ metadata: input.metadata ?? {},
133
+ },
134
+ ctx,
135
+ );
136
+ return Ok(interaction);
137
+ }
138
+
139
+ async updateInteraction(
140
+ customerId: string,
141
+ interactionId: string,
142
+ input: { kind?: string | undefined; notes?: string | undefined; relatedEntityId?: string | null | undefined; metadata?: Record<string, unknown> | undefined },
143
+ actor?: Actor | null,
144
+ ctx?: TxContext,
145
+ ): Promise<Result<CustomerInteraction>> {
146
+ try { assertPermission(actor ?? null, "customers:update"); } catch (error) { return Err(toCommerceError(error)); }
147
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
148
+ const existing = await this.repo.findInteractionById(orgId, interactionId, ctx);
149
+ if (!existing || existing.customerId !== customerId) return Err(new CommerceNotFoundError("Interaction not found."));
150
+ const patch = {
151
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
152
+ ...(input.notes !== undefined ? { notes: input.notes } : {}),
153
+ ...(input.relatedEntityId !== undefined ? { relatedEntityId: input.relatedEntityId } : {}),
154
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
155
+ };
156
+ const updated = await this.repo.updateInteraction(orgId, interactionId, patch, ctx);
157
+ if (!updated) return Err(new CommerceNotFoundError("Interaction not found."));
158
+ return Ok(updated);
159
+ }
160
+
161
+ async deleteInteraction(customerId: string, interactionId: string, actor?: Actor | null, ctx?: TxContext): Promise<Result<void>> {
162
+ try { assertPermission(actor ?? null, "customers:update"); } catch (error) { return Err(toCommerceError(error)); }
163
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
164
+ const existing = await this.repo.findInteractionById(orgId, interactionId, ctx);
165
+ if (!existing || existing.customerId !== customerId) return Err(new CommerceNotFoundError("Interaction not found."));
166
+ await this.repo.deleteInteraction(orgId, interactionId, ctx);
167
+ return Ok(undefined);
168
+ }
169
+
50
170
  private async getOrCreateByUserId(
51
171
  orgId: string,
52
172
  userId: string,
@@ -119,11 +239,26 @@ export class CustomerService {
119
239
  >,
120
240
  actor?: Actor | null,
121
241
  ctx?: TxContext,
242
+ options?: { replaceMetadata?: boolean },
122
243
  ): Promise<Result<Customer>> {
123
244
  const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
124
245
  const existing = await this.repo.findById(orgId, id, ctx);
125
246
  if (!existing) return Err(new CommerceNotFoundError("Customer not found."));
126
- const updated = await this.repo.update(id, updates, ctx);
247
+
248
+ // Shallow-merge metadata by default (top-level keys) so a single-key edit
249
+ // doesn't clobber the rest of the blob. Pass replaceMetadata to overwrite.
250
+ let finalUpdates = updates;
251
+ if (updates.metadata !== undefined && !options?.replaceMetadata) {
252
+ finalUpdates = {
253
+ ...updates,
254
+ metadata: {
255
+ ...((existing.metadata as Record<string, unknown> | null) ?? {}),
256
+ ...(updates.metadata as Record<string, unknown>),
257
+ },
258
+ };
259
+ }
260
+
261
+ const updated = await this.repo.update(id, finalUpdates, ctx);
127
262
  if (!updated) return Err(new CommerceNotFoundError("Customer not found."));
128
263
 
129
264
  const afterHooks = this.deps.hooks.resolve(
@@ -6,7 +6,12 @@ export const InventoryAdjustBodySchema = z.object({
6
6
  entityId: z.string().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
7
7
  variantId: z.string().optional().openapi({ example: "variant-uuid" }),
8
8
  warehouseId: z.string().optional().openapi({ example: "warehouse-uuid" }),
9
- adjustment: z.number().int().refine((v) => v !== 0, { message: "Adjustment cannot be zero" }).openapi({ example: 10 }),
9
+ // Signed-delta form (legacy/default). Required when `mode` is omitted.
10
+ adjustment: z.number().int().refine((v) => v !== 0, { message: "Adjustment cannot be zero" }).optional().openapi({ example: 10 }),
11
+ // Mode form. `add`/`remove`/`set` operate on the unsigned `amount`.
12
+ // `remove` clamps at 0; `set` writes the absolute quantity.
13
+ mode: z.enum(["add", "remove", "set"]).optional().openapi({ example: "add" }),
14
+ amount: z.number().int().min(0).optional().openapi({ example: 5 }),
10
15
  reason: z.string().openapi({ example: "Restock from supplier" }),
11
16
  performedBy: z.string().optional(),
12
17
  referenceType: z.string().optional(),