@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.
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +17 -1
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js +6 -0
- package/dist/config/types.d.ts +12 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout.d.ts +3 -3
- package/dist/hooks/checkout.js +4 -4
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
- package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
- package/dist/interfaces/rest/audit-middleware.js +82 -0
- package/dist/interfaces/rest/parse-json.d.ts +39 -0
- package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
- package/dist/interfaces/rest/parse-json.js +45 -0
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -2
- package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/customers.js +59 -4
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +4 -2
- package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/media.js +6 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +23 -1
- package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/promotions.js +21 -1
- package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +28 -1
- package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
- package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customers.js +88 -0
- package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
- package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/media.d.ts +105 -0
- package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +20 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
- package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/promotions.js +25 -1
- package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
- package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/shared.js +8 -0
- package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
- package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
- package/dist/kernel/database/adapter.d.ts +6 -0
- package/dist/kernel/database/adapter.d.ts.map +1 -1
- package/dist/kernel/database/adapter.js +62 -1
- package/dist/kernel/database/migrate.d.ts +22 -2
- package/dist/kernel/database/migrate.d.ts.map +1 -1
- package/dist/kernel/database/migrate.js +42 -2
- package/dist/modules/analytics/hooks.d.ts +5 -5
- package/dist/modules/analytics/hooks.js +5 -5
- package/dist/modules/analytics/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/category-service.d.ts +6 -1
- package/dist/modules/catalog/category-service.d.ts.map +1 -1
- package/dist/modules/catalog/category-service.js +35 -5
- package/dist/modules/catalog/schema.d.ts +17 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +2 -0
- package/dist/modules/catalog/service.d.ts +11 -2
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +8 -2
- package/dist/modules/customers/repository/index.d.ts +8 -1
- package/dist/modules/customers/repository/index.d.ts.map +1 -1
- package/dist/modules/customers/repository/index.js +42 -2
- package/dist/modules/customers/schema.d.ts +162 -0
- package/dist/modules/customers/schema.d.ts.map +1 -1
- package/dist/modules/customers/schema.js +18 -0
- package/dist/modules/customers/service.d.ts +32 -2
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +119 -3
- package/dist/modules/inventory/schemas.d.ts +7 -1
- package/dist/modules/inventory/schemas.d.ts.map +1 -1
- package/dist/modules/inventory/schemas.js +6 -1
- package/dist/modules/inventory/service.d.ts +18 -0
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +58 -21
- package/dist/modules/media/noop-adapter.d.ts +11 -0
- package/dist/modules/media/noop-adapter.d.ts.map +1 -0
- package/dist/modules/media/noop-adapter.js +31 -0
- package/dist/modules/orders/repository/index.d.ts +19 -0
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +42 -1
- package/dist/modules/orders/service.d.ts +31 -0
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +52 -2
- package/dist/modules/payments/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/schemas.d.ts +46 -0
- package/dist/modules/promotions/schemas.d.ts.map +1 -1
- package/dist/modules/promotions/schemas.js +19 -8
- package/dist/modules/promotions/service.d.ts +8 -2
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +74 -9
- package/dist/modules/search/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/runtime/client-ip.d.ts +31 -0
- package/dist/runtime/client-ip.d.ts.map +1 -0
- package/dist/runtime/client-ip.js +31 -0
- package/dist/runtime/server.d.ts +4 -0
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +35 -19
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +4 -1
- package/dist/test-utils/test-actors.d.ts +3 -2
- package/dist/test-utils/test-actors.d.ts.map +1 -1
- package/dist/test-utils/test-actors.js +3 -2
- package/package.json +4 -4
- package/src/auth/middleware.ts +18 -1
- package/src/config/define-config.ts +7 -0
- package/src/config/types.ts +12 -0
- package/src/hooks/checkout.ts +4 -4
- package/src/index.ts +18 -1
- package/src/interfaces/rest/audit-middleware.ts +110 -0
- package/src/interfaces/rest/parse-json.ts +81 -0
- package/src/interfaces/rest/routes/catalog.ts +21 -1
- package/src/interfaces/rest/routes/customers.ts +64 -3
- package/src/interfaces/rest/routes/inventory.ts +4 -2
- package/src/interfaces/rest/routes/media.ts +7 -0
- package/src/interfaces/rest/routes/orders.ts +21 -1
- package/src/interfaces/rest/routes/promotions.ts +22 -1
- package/src/interfaces/rest/schemas/catalog.ts +30 -1
- package/src/interfaces/rest/schemas/customers.ts +99 -0
- package/src/interfaces/rest/schemas/orders.ts +21 -0
- package/src/interfaces/rest/schemas/promotions.ts +32 -1
- package/src/interfaces/rest/schemas/shared.ts +9 -0
- package/src/kernel/database/adapter.ts +64 -1
- package/src/kernel/database/migrate.ts +50 -2
- package/src/modules/analytics/hooks.ts +5 -5
- package/src/modules/analytics/repository/index.ts +0 -1
- package/src/modules/catalog/category-service.ts +26 -5
- package/src/modules/catalog/schema.ts +2 -0
- package/src/modules/catalog/service.ts +25 -3
- package/src/modules/customers/repository/index.ts +55 -1
- package/src/modules/customers/schema.ts +19 -0
- package/src/modules/customers/service.ts +137 -2
- package/src/modules/inventory/schemas.ts +6 -1
- package/src/modules/inventory/service.ts +84 -29
- package/src/modules/media/noop-adapter.ts +36 -0
- package/src/modules/orders/repository/index.ts +63 -1
- package/src/modules/orders/service.ts +86 -3
- package/src/modules/payments/repository/index.ts +0 -1
- package/src/modules/promotions/schemas.ts +21 -4
- package/src/modules/promotions/service.ts +90 -18
- package/src/modules/search/repository/index.ts +0 -1
- package/src/modules/shipping/repository/index.ts +0 -1
- package/src/modules/tax/repository/index.ts +0 -1
- package/src/runtime/client-ip.ts +49 -0
- package/src/runtime/server.ts +39 -18
- package/src/test-utils/create-plugin-test-app.ts +3 -1
- 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 {
|
|
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:
|
|
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 —
|
|
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
|
-
*
|
|
6
|
+
* (orders, inventory_levels, customers) ARE the source of truth; the
|
|
7
|
+
* DrizzleAnalyticsAdapter queries them directly via SQL.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* The empty exports below preserve backwards compatibility for code
|
|
10
|
+
* still importing from this module.
|
|
11
11
|
*/
|
|
@@ -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
|
|
35
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|