@porulle/core 0.10.6 → 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.
- package/dist/config/types.d.ts +3 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout.d.ts +2 -0
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +4 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +37 -2
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +7 -1
- package/dist/interfaces/rest/routes/payments.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/payments.js +48 -30
- package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/search.js +12 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +86 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +1 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.js +1 -0
- package/dist/kernel/error-mapper.d.ts.map +1 -1
- package/dist/kernel/error-mapper.js +3 -0
- package/dist/modules/catalog/entity-service.d.ts +5 -3
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +73 -22
- package/dist/modules/catalog/module.d.ts +6 -1
- package/dist/modules/catalog/module.d.ts.map +1 -1
- package/dist/modules/catalog/module.js +6 -1
- package/dist/modules/catalog/ownership.d.ts +5 -0
- package/dist/modules/catalog/ownership.d.ts.map +1 -0
- package/dist/modules/catalog/ownership.js +11 -0
- package/dist/modules/catalog/repository/index.d.ts +52 -1
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +321 -2
- package/dist/modules/catalog/revision.d.ts +4 -0
- package/dist/modules/catalog/revision.d.ts.map +1 -0
- package/dist/modules/catalog/revision.js +33 -0
- package/dist/modules/catalog/schema.d.ts +979 -110
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +112 -2
- package/dist/modules/catalog/schemas.d.ts +2 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +2 -0
- package/dist/modules/catalog/service.d.ts +66 -3
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +512 -11
- package/dist/modules/channels/adapter.d.ts +85 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -1
- package/dist/modules/channels/adapter.js +9 -1
- package/dist/modules/media/module.d.ts +68 -0
- package/dist/modules/media/module.d.ts.map +1 -1
- package/dist/modules/media/module.js +2 -0
- package/dist/modules/media/schema.d.ts +70 -0
- package/dist/modules/media/schema.d.ts.map +1 -1
- package/dist/modules/media/schema.js +20 -2
- package/dist/modules/media/service.d.ts +8 -1
- package/dist/modules/media/service.d.ts.map +1 -1
- package/dist/modules/media/service.js +66 -18
- package/dist/modules/orders/service.d.ts +2 -0
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +1 -0
- package/dist/modules/pricing/schema.d.ts +17 -0
- package/dist/modules/pricing/schema.d.ts.map +1 -1
- package/dist/modules/pricing/schema.js +1 -0
- package/dist/modules/pricing/schemas.d.ts +1 -0
- package/dist/modules/pricing/schemas.d.ts.map +1 -1
- package/dist/modules/pricing/schemas.js +1 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +6 -1
- package/dist/modules/search/adapter.d.ts +2 -0
- package/dist/modules/search/adapter.d.ts.map +1 -1
- package/dist/modules/search/module.d.ts.map +1 -1
- package/dist/modules/search/module.js +2 -0
- package/dist/modules/search/service.d.ts +4 -0
- package/dist/modules/search/service.d.ts.map +1 -1
- package/dist/modules/search/service.js +74 -5
- package/dist/runtime/kernel-modules.d.ts +73 -0
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/dist/utils/id.d.ts +1 -0
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +8 -0
- package/package.json +1 -1
- package/src/config/types.ts +3 -0
- package/src/hooks/checkout.ts +4 -0
- package/src/index.ts +14 -0
- package/src/interfaces/rest/index.ts +4 -0
- package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
- package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +85 -0
- package/src/interfaces/rest/routes/checkout.ts +7 -1
- package/src/interfaces/rest/routes/payments.ts +55 -35
- package/src/interfaces/rest/routes/search.ts +16 -0
- package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
- package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
- package/src/interfaces/rest/schemas/catalog.ts +92 -0
- package/src/interfaces/rest/schemas/search.ts +2 -0
- package/src/kernel/error-mapper.ts +3 -0
- package/src/modules/catalog/entity-service.ts +73 -20
- package/src/modules/catalog/module.ts +15 -0
- package/src/modules/catalog/ownership.ts +17 -0
- package/src/modules/catalog/repository/index.ts +547 -1
- package/src/modules/catalog/revision.ts +35 -0
- package/src/modules/catalog/schema.ts +169 -1
- package/src/modules/catalog/schemas.ts +2 -0
- package/src/modules/catalog/service.ts +738 -13
- package/src/modules/channels/adapter.ts +107 -1
- package/src/modules/media/module.ts +2 -0
- package/src/modules/media/schema.ts +23 -2
- package/src/modules/media/service.ts +84 -23
- package/src/modules/orders/service.ts +3 -0
- package/src/modules/pricing/schema.ts +1 -0
- package/src/modules/pricing/schemas.ts +1 -0
- package/src/modules/pricing/service.ts +7 -2
- package/src/modules/search/adapter.ts +2 -0
- package/src/modules/search/module.ts +2 -0
- package/src/modules/search/service.ts +87 -6
- package/src/utils/id.ts +11 -0
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -10,6 +10,7 @@ export interface ChannelConnectorCapabilities {
|
|
|
10
10
|
readonly importCatalog: boolean;
|
|
11
11
|
readonly importInventory: boolean;
|
|
12
12
|
readonly pushOrder: boolean;
|
|
13
|
+
readonly pushCatalog?: boolean;
|
|
13
14
|
readonly receiveWebhooks: boolean;
|
|
14
15
|
readonly reserve?: boolean;
|
|
15
16
|
}
|
|
@@ -24,11 +25,49 @@ export interface ChannelStore {
|
|
|
24
25
|
webhookSecret: string | null;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export interface ChannelCatalogLocalizedAttributes {
|
|
29
|
+
locale: string;
|
|
30
|
+
title: string;
|
|
31
|
+
subtitle?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
richDescription?: unknown;
|
|
34
|
+
seoTitle?: string;
|
|
35
|
+
seoDescription?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChannelCatalogImage {
|
|
39
|
+
externalId?: string;
|
|
40
|
+
url: string;
|
|
41
|
+
alt?: string;
|
|
42
|
+
role: "primary" | "gallery" | "thumbnail" | "video" | "document";
|
|
43
|
+
sortOrder?: number;
|
|
44
|
+
variantExternalIds?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChannelCatalogOptionType {
|
|
48
|
+
name: string;
|
|
49
|
+
displayName: string;
|
|
50
|
+
sortOrder?: number;
|
|
51
|
+
values: Array<{
|
|
52
|
+
value: string;
|
|
53
|
+
displayValue: string;
|
|
54
|
+
sortOrder?: number;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ChannelCatalogPrice {
|
|
59
|
+
currency: string;
|
|
60
|
+
amount: number;
|
|
61
|
+
compareAtAmount?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
27
64
|
export interface ChannelCatalogVariant {
|
|
28
65
|
externalId: string;
|
|
29
66
|
sku?: string;
|
|
30
67
|
barcode?: string;
|
|
31
68
|
metadata?: Record<string, unknown>;
|
|
69
|
+
optionValues?: Record<string, string>;
|
|
70
|
+
prices?: ChannelCatalogPrice[];
|
|
32
71
|
}
|
|
33
72
|
|
|
34
73
|
export interface ChannelCatalogItem {
|
|
@@ -38,6 +77,13 @@ export interface ChannelCatalogItem {
|
|
|
38
77
|
description?: string;
|
|
39
78
|
variants: ChannelCatalogVariant[];
|
|
40
79
|
metadata?: Record<string, unknown>;
|
|
80
|
+
attributes?: ChannelCatalogLocalizedAttributes[];
|
|
81
|
+
images?: ChannelCatalogImage[];
|
|
82
|
+
options?: ChannelCatalogOptionType[];
|
|
83
|
+
tags?: string[];
|
|
84
|
+
brand?: string;
|
|
85
|
+
categories?: string[];
|
|
86
|
+
status?: "draft" | "active" | "archived" | "discontinued";
|
|
41
87
|
}
|
|
42
88
|
|
|
43
89
|
export interface ChannelCatalogPage {
|
|
@@ -76,6 +122,54 @@ export interface ChannelPushOrderResult {
|
|
|
76
122
|
remoteUrl?: string;
|
|
77
123
|
}
|
|
78
124
|
|
|
125
|
+
export type ChannelPushCatalogIntent = "filterable" | "display" | "tag";
|
|
126
|
+
|
|
127
|
+
export interface ChannelPushCatalogField {
|
|
128
|
+
fieldPath: string;
|
|
129
|
+
intent: ChannelPushCatalogIntent;
|
|
130
|
+
value: unknown;
|
|
131
|
+
locale?: string;
|
|
132
|
+
remoteKey?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ChannelPushCatalogImage {
|
|
136
|
+
externalId?: string;
|
|
137
|
+
url: string;
|
|
138
|
+
alt?: string;
|
|
139
|
+
role: "primary" | "gallery" | "thumbnail" | "video" | "document";
|
|
140
|
+
sortOrder?: number;
|
|
141
|
+
variantExternalIds?: string[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ChannelPushCatalogVariant {
|
|
145
|
+
externalId: string;
|
|
146
|
+
fields: ChannelPushCatalogField[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ChannelPushCatalogItem {
|
|
150
|
+
externalId: string;
|
|
151
|
+
variants?: ChannelPushCatalogVariant[];
|
|
152
|
+
fields: ChannelPushCatalogField[];
|
|
153
|
+
images?: ChannelPushCatalogImage[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ChannelPushCatalogPreviousField {
|
|
157
|
+
fieldPath: string;
|
|
158
|
+
value: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ChannelPushCatalogItemOutcome {
|
|
162
|
+
externalId: string;
|
|
163
|
+
ok: boolean;
|
|
164
|
+
error?: ChannelConnectorError;
|
|
165
|
+
remoteUpdatedAt?: string;
|
|
166
|
+
previousFields?: ChannelPushCatalogPreviousField[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ChannelPushCatalogResult {
|
|
170
|
+
outcomes: ChannelPushCatalogItemOutcome[];
|
|
171
|
+
}
|
|
172
|
+
|
|
79
173
|
export interface ChannelOrderStatus {
|
|
80
174
|
status: "pending" | "confirmed" | "failed" | "cancelled" | "fulfilled";
|
|
81
175
|
}
|
|
@@ -113,6 +207,11 @@ export interface ChannelConnector {
|
|
|
113
207
|
importCatalog(store: ChannelStore, cursor?: string): Promise<Result<ChannelCatalogPage>>;
|
|
114
208
|
fetchInventory(store: ChannelStore, ids?: string[]): Promise<Result<ChannelInventoryLevel[]>>;
|
|
115
209
|
pushOrder(store: ChannelStore, slice: ChannelOrderSlice): Promise<Result<ChannelPushOrderResult, ChannelConnectorError>>;
|
|
210
|
+
pushCatalog?(
|
|
211
|
+
store: ChannelStore,
|
|
212
|
+
items: ChannelPushCatalogItem[],
|
|
213
|
+
opts?: { dryRun?: boolean },
|
|
214
|
+
): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>>;
|
|
116
215
|
fetchOrderStatus(store: ChannelStore, remoteId: string): Promise<Result<ChannelOrderStatus, ChannelConnectorError>>;
|
|
117
216
|
verifyWebhook(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent>>;
|
|
118
217
|
verifyAppWebhook?(
|
|
@@ -135,5 +234,12 @@ export interface ChannelConnector {
|
|
|
135
234
|
}
|
|
136
235
|
|
|
137
236
|
export function defineChannelConnector<T extends ChannelConnector>(connector: T): T {
|
|
138
|
-
return connector;
|
|
237
|
+
if (connector.capabilities.pushCatalog !== undefined) return connector;
|
|
238
|
+
return {
|
|
239
|
+
...connector,
|
|
240
|
+
capabilities: {
|
|
241
|
+
...connector.capabilities,
|
|
242
|
+
pushCatalog: false,
|
|
243
|
+
},
|
|
244
|
+
} as T;
|
|
139
245
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import { index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
4
|
import { organization } from "../../auth/auth-schema.js";
|
|
3
5
|
import { sellableEntities, variants } from "../catalog/schema.js";
|
|
4
6
|
|
|
7
|
+
export const mediaAssetOrigin = pgEnum("media_asset_origin", ["merchant", "generated", "imported"]);
|
|
8
|
+
export type MediaAssetOrigin = (typeof mediaAssetOrigin.enumValues)[number];
|
|
9
|
+
|
|
5
10
|
export const mediaAssets = pgTable(
|
|
6
11
|
"media_assets",
|
|
7
12
|
{
|
|
@@ -17,6 +22,11 @@ export const mediaAssets = pgTable(
|
|
|
17
22
|
height: integer("height"),
|
|
18
23
|
alt: text("alt"),
|
|
19
24
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
25
|
+
origin: mediaAssetOrigin("origin").notNull().default("merchant"),
|
|
26
|
+
confidence: numeric("confidence", { precision: 4, scale: 3 }),
|
|
27
|
+
derivedFromAssetId: uuid("derived_from_asset_id").references((): AnyPgColumn => mediaAssets.id, {
|
|
28
|
+
onDelete: "set null",
|
|
29
|
+
}),
|
|
20
30
|
uploadedAt: timestamp("uploaded_at", { withTimezone: true }).defaultNow().notNull(),
|
|
21
31
|
},
|
|
22
32
|
(table) => ({
|
|
@@ -38,4 +48,15 @@ export const entityMedia = pgTable("entity_media", {
|
|
|
38
48
|
enum: ["primary", "gallery", "thumbnail", "video", "document"],
|
|
39
49
|
}).notNull(),
|
|
40
50
|
sortOrder: integer("sort_order").notNull().default(0),
|
|
41
|
-
})
|
|
51
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
52
|
+
}, (table) => ({
|
|
53
|
+
// Two partial indexes instead of one NULLS DISTINCT index: Postgres treats
|
|
54
|
+
// null variant ids as distinct, so a single index cannot reject duplicate
|
|
55
|
+
// entity-level rows.
|
|
56
|
+
entityLevelUnique: uniqueIndex("entity_media_entity_level_unique")
|
|
57
|
+
.on(table.entityId, table.mediaAssetId)
|
|
58
|
+
.where(sql`${table.variantId} IS NULL`),
|
|
59
|
+
variantLevelUnique: uniqueIndex("entity_media_variant_level_unique")
|
|
60
|
+
.on(table.entityId, table.variantId, table.mediaAssetId)
|
|
61
|
+
.where(sql`${table.variantId} IS NOT NULL`),
|
|
62
|
+
}));
|
|
@@ -1,16 +1,22 @@
|
|
|
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
4
|
import type { CommerceConfig } from "../../config/types.js";
|
|
4
5
|
import type { StorageAdapter, StoredFile } from "./adapter.js";
|
|
5
6
|
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
6
7
|
import {
|
|
8
|
+
CommerceConflictError,
|
|
7
9
|
CommerceNotFoundError,
|
|
8
10
|
CommerceValidationError,
|
|
11
|
+
toCommerceError,
|
|
9
12
|
} from "../../kernel/errors.js";
|
|
10
|
-
import type { MediaRepository } from "./repository/index.js";
|
|
13
|
+
import type { MediaRepository, MediaAsset } from "./repository/index.js";
|
|
11
14
|
import type { CatalogRepository } from "../catalog/repository/index.js";
|
|
12
15
|
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
16
|
+
import { createTxContext } from "../../kernel/database/tx-context.js";
|
|
17
|
+
import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
|
|
13
18
|
import { makeId } from "../../utils/id.js";
|
|
19
|
+
import type { MediaAssetOrigin } from "./schema.js";
|
|
14
20
|
|
|
15
21
|
export interface UploadMediaInput {
|
|
16
22
|
filename: string;
|
|
@@ -18,6 +24,7 @@ export interface UploadMediaInput {
|
|
|
18
24
|
data: ArrayBuffer;
|
|
19
25
|
alt?: string;
|
|
20
26
|
metadata?: Record<string, unknown>;
|
|
27
|
+
origin?: MediaAssetOrigin;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export interface AttachMediaInput {
|
|
@@ -43,6 +50,8 @@ interface MediaServiceDeps {
|
|
|
43
50
|
catalogRepository: CatalogRepository;
|
|
44
51
|
storage: StorageAdapter;
|
|
45
52
|
config: CommerceConfig;
|
|
53
|
+
database?: DatabaseAdapter;
|
|
54
|
+
services?: Record<string, unknown>;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const DEFAULT_ALLOWED_MIME_TYPES = [
|
|
@@ -53,6 +62,17 @@ const DEFAULT_ALLOWED_MIME_TYPES = [
|
|
|
53
62
|
"application/pdf",
|
|
54
63
|
];
|
|
55
64
|
|
|
65
|
+
export function assertMediaWritable(asset: Pick<MediaAsset, "origin">, actor: Actor | null): void {
|
|
66
|
+
if (asset.origin === "merchant") assertPermission(actor, "media:write");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
70
|
+
if (error == null || typeof error !== "object") return false;
|
|
71
|
+
const value = error as { code?: unknown; cause?: unknown };
|
|
72
|
+
if (value.code === "23505") return true;
|
|
73
|
+
return isUniqueViolation(value.cause);
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
function isSvgOrXml(buffer: Uint8Array): boolean {
|
|
57
77
|
const prefix = new TextDecoder()
|
|
58
78
|
.decode(buffer.slice(0, 256))
|
|
@@ -97,6 +117,16 @@ export class MediaService {
|
|
|
97
117
|
this.catalogRepo = deps.catalogRepository;
|
|
98
118
|
}
|
|
99
119
|
|
|
120
|
+
private async withTransaction<T>(
|
|
121
|
+
actor: Actor | null,
|
|
122
|
+
ctx: TxContext | undefined,
|
|
123
|
+
fn: (txCtx: TxContext) => Promise<T>,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
if (ctx?.tx != null) return fn(ctx);
|
|
126
|
+
if (this.deps.database == null) throw new Error("Database is required for media mutations.");
|
|
127
|
+
return this.deps.database.transaction(async (tx) => fn(createTxContext(tx, { actor })));
|
|
128
|
+
}
|
|
129
|
+
|
|
100
130
|
async upload(
|
|
101
131
|
input: UploadMediaInput,
|
|
102
132
|
actor?: Actor | null,
|
|
@@ -148,6 +178,7 @@ export class MediaService {
|
|
|
148
178
|
contentType: effectiveMime,
|
|
149
179
|
size: input.data.byteLength,
|
|
150
180
|
metadata: input.metadata ?? {},
|
|
181
|
+
origin: input.origin ?? "merchant",
|
|
151
182
|
uploadedAt: new Date(),
|
|
152
183
|
...(input.alt !== undefined ? { alt: input.alt } : {}),
|
|
153
184
|
},
|
|
@@ -199,6 +230,12 @@ export class MediaService {
|
|
|
199
230
|
const asset = await this.repo.findAssetById(id, ctx, orgId);
|
|
200
231
|
if (!asset) return Err(new CommerceNotFoundError("Media asset not found."));
|
|
201
232
|
|
|
233
|
+
try {
|
|
234
|
+
assertMediaWritable(asset, actor ?? ctx?.actor ?? null);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return Err(toCommerceError(error));
|
|
237
|
+
}
|
|
238
|
+
|
|
202
239
|
const deleted = await this.deps.storage.delete(asset.storageKey);
|
|
203
240
|
if (!deleted.ok) return deleted;
|
|
204
241
|
|
|
@@ -218,31 +255,55 @@ export class MediaService {
|
|
|
218
255
|
actor?: Actor | null,
|
|
219
256
|
ctx?: TxContext,
|
|
220
257
|
): Promise<Result<void>> {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
try {
|
|
259
|
+
return await this.withTransaction(actor ?? ctx?.actor ?? null, ctx, async (txCtx) => {
|
|
260
|
+
const orgId = resolveOrgId(actor ?? txCtx.actor);
|
|
261
|
+
const entity = await this.catalogRepo.findEntityById(input.entityId, txCtx, orgId);
|
|
262
|
+
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
226
263
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return Err(new CommerceNotFoundError("Media asset not found."));
|
|
230
|
-
}
|
|
264
|
+
const asset = await this.repo.findAssetById(input.mediaAssetId, txCtx, orgId);
|
|
265
|
+
if (!asset) return Err(new CommerceNotFoundError("Media asset not found."));
|
|
231
266
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
role: input.role,
|
|
237
|
-
sortOrder: input.sortOrder ?? 0,
|
|
238
|
-
...(input.variantId !== undefined
|
|
239
|
-
? { variantId: input.variantId }
|
|
240
|
-
: {}),
|
|
241
|
-
},
|
|
242
|
-
ctx,
|
|
243
|
-
);
|
|
267
|
+
const existing = await this.repo.findEntityMedia(input.entityId, input.variantId, txCtx);
|
|
268
|
+
if (existing.some((link) => link.mediaAssetId === input.mediaAssetId)) {
|
|
269
|
+
return Err(new CommerceConflictError("Media asset is already attached at this level."));
|
|
270
|
+
}
|
|
244
271
|
|
|
245
|
-
|
|
272
|
+
await this.repo.createEntityMedia(
|
|
273
|
+
{
|
|
274
|
+
entityId: input.entityId,
|
|
275
|
+
mediaAssetId: input.mediaAssetId,
|
|
276
|
+
role: input.role,
|
|
277
|
+
sortOrder: input.sortOrder ?? 0,
|
|
278
|
+
...(input.variantId !== undefined
|
|
279
|
+
? { variantId: input.variantId }
|
|
280
|
+
: {}),
|
|
281
|
+
},
|
|
282
|
+
txCtx,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const catalog = this.deps.services?.catalog;
|
|
286
|
+
if (catalog == null || typeof catalog !== "object") {
|
|
287
|
+
throw new Error("Catalog service is required for media revision capture.");
|
|
288
|
+
}
|
|
289
|
+
const catalogService = catalog as {
|
|
290
|
+
recordEntityRevision: (
|
|
291
|
+
entityId: string,
|
|
292
|
+
actor: Actor | null,
|
|
293
|
+
reason?: "create" | "update" | "import" | "enrichment" | "push" | "restore",
|
|
294
|
+
ctx?: TxContext,
|
|
295
|
+
) => Promise<Result<unknown>>;
|
|
296
|
+
};
|
|
297
|
+
const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
|
|
298
|
+
if (!revision.ok) throw revision.error;
|
|
299
|
+
return Ok(undefined);
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (isUniqueViolation(error)) {
|
|
303
|
+
return Err(new CommerceConflictError("Media asset is already attached at this level."));
|
|
304
|
+
}
|
|
305
|
+
return Err(toCommerceError(error));
|
|
306
|
+
}
|
|
246
307
|
}
|
|
247
308
|
|
|
248
309
|
/**
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
} from "./repository/index.js";
|
|
43
43
|
|
|
44
44
|
export interface CreateOrderInput {
|
|
45
|
+
/** Server-allocated id for trusted pipelines that must reference the order before persistence. */
|
|
46
|
+
id?: string;
|
|
45
47
|
customerId?: string;
|
|
46
48
|
/** Client-supplied retry key — a repeat create with the same key returns the original order. */
|
|
47
49
|
idempotencyKey?: string | undefined;
|
|
@@ -379,6 +381,7 @@ export class OrderService {
|
|
|
379
381
|
try {
|
|
380
382
|
order = await this.repo.create(
|
|
381
383
|
{
|
|
384
|
+
...(opts?.trustedPricing === true && processed.id != null ? { id: processed.id } : {}),
|
|
382
385
|
organizationId: orgId,
|
|
383
386
|
orderNumber,
|
|
384
387
|
status: "pending",
|
|
@@ -15,6 +15,7 @@ export const prices = pgTable(
|
|
|
15
15
|
variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }),
|
|
16
16
|
currency: text("currency").notNull(),
|
|
17
17
|
amount: integer("amount").notNull(),
|
|
18
|
+
compareAtAmount: integer("compare_at_amount"),
|
|
18
19
|
customerGroupId: text("customer_group_id"),
|
|
19
20
|
minQuantity: integer("min_quantity"),
|
|
20
21
|
maxQuantity: integer("max_quantity"),
|
|
@@ -7,6 +7,7 @@ export const SetBasePriceBodySchema = z.object({
|
|
|
7
7
|
variantId: z.string().optional().openapi({ example: "variant-uuid" }),
|
|
8
8
|
currency: z.string().length(3).openapi({ example: "USD" }),
|
|
9
9
|
amount: z.number().openapi({ example: 29.99 }),
|
|
10
|
+
compareAtAmount: z.number().nullable().optional(),
|
|
10
11
|
customerGroupId: z.string().optional(),
|
|
11
12
|
minQuantity: z.number().int().optional(),
|
|
12
13
|
maxQuantity: z.number().int().optional(),
|
|
@@ -224,6 +224,7 @@ export class PricingService {
|
|
|
224
224
|
entityId: input.entityId,
|
|
225
225
|
currency: normalizeCurrency(input.currency),
|
|
226
226
|
amount: input.amount,
|
|
227
|
+
...(input.compareAtAmount !== undefined ? { compareAtAmount: input.compareAtAmount } : {}),
|
|
227
228
|
metadata: input.metadata ?? {},
|
|
228
229
|
variantId: input.variantId ?? null,
|
|
229
230
|
customerGroupId: input.customerGroupId ?? null,
|
|
@@ -252,8 +253,12 @@ export class PricingService {
|
|
|
252
253
|
|
|
253
254
|
const record = existing
|
|
254
255
|
? (await this.repo.updatePrice(
|
|
255
|
-
|
|
256
|
-
{
|
|
256
|
+
existing.id,
|
|
257
|
+
{
|
|
258
|
+
amount: priceData.amount,
|
|
259
|
+
...(priceData.compareAtAmount !== undefined ? { compareAtAmount: priceData.compareAtAmount } : {}),
|
|
260
|
+
metadata: priceData.metadata,
|
|
261
|
+
},
|
|
257
262
|
ctx,
|
|
258
263
|
)) ?? existing
|
|
259
264
|
: await this.repo.createPrice(priceData, ctx);
|
|
@@ -10,6 +10,7 @@ export interface SearchDocument {
|
|
|
10
10
|
categories: string[];
|
|
11
11
|
brands: string[];
|
|
12
12
|
text: string;
|
|
13
|
+
attributes?: Record<string, string | string[]>;
|
|
13
14
|
payload?: Record<string, unknown>;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -18,6 +19,7 @@ export interface SearchFilters {
|
|
|
18
19
|
category?: string;
|
|
19
20
|
brand?: string;
|
|
20
21
|
status?: string;
|
|
22
|
+
attributes?: Record<string, string | string[]>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface SearchQueryParams {
|
|
@@ -16,6 +16,8 @@ export const searchModule = defineModule<Record<string, never>, SearchService, S
|
|
|
16
16
|
const config = deps.config as CommerceConfig;
|
|
17
17
|
return new SearchService({
|
|
18
18
|
catalogRepository: deps.services.catalog.repository,
|
|
19
|
+
resolveEntityFieldDefinitions: deps.services.catalog.resolveEntityFieldDefinitions.bind(deps.services.catalog),
|
|
20
|
+
...(config.entities ? { entities: config.entities } : {}),
|
|
19
21
|
...(config.search?.adapter ? { adapter: config.search.adapter } : {}),
|
|
20
22
|
...(config.search?.defaultFacets
|
|
21
23
|
? { defaultFacets: config.search.defaultFacets }
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Ok, type Result } from "../../kernel/result.js";
|
|
2
2
|
import { resolveOrgId } from "../../auth/org.js";
|
|
3
3
|
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { CommerceConfig } from "../../config/types.js";
|
|
5
|
+
import type {
|
|
6
|
+
CatalogRepository,
|
|
7
|
+
SellableCustomField,
|
|
8
|
+
SellableEntity,
|
|
9
|
+
} from "../catalog/repository/index.js";
|
|
5
10
|
import type {
|
|
6
11
|
SearchAdapter,
|
|
7
12
|
SearchDocument,
|
|
@@ -10,9 +15,12 @@ import type {
|
|
|
10
15
|
SearchQueryResult,
|
|
11
16
|
SearchSuggestParams,
|
|
12
17
|
} from "./adapter.js";
|
|
18
|
+
import type { EntityFieldDefinitionResolver } from "../catalog/service.js";
|
|
13
19
|
|
|
14
20
|
interface SearchServiceDeps {
|
|
15
21
|
catalogRepository: CatalogRepository;
|
|
22
|
+
entities?: CommerceConfig["entities"];
|
|
23
|
+
resolveEntityFieldDefinitions?: EntityFieldDefinitionResolver;
|
|
16
24
|
adapter?: SearchAdapter;
|
|
17
25
|
defaultFacets?: string[];
|
|
18
26
|
}
|
|
@@ -39,6 +47,19 @@ function includesAllTokens(haystack: string, tokens: string[]): boolean {
|
|
|
39
47
|
return tokens.every((token) => lower.includes(token));
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
function customFieldValue(field: SellableCustomField): string | undefined {
|
|
51
|
+
if (field.textValue != null) return field.textValue;
|
|
52
|
+
if (field.numberValue != null) return String(field.numberValue);
|
|
53
|
+
if (field.booleanValue != null) return String(field.booleanValue);
|
|
54
|
+
if (field.dateValue != null) return field.dateValue.toISOString();
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attributeValues(value: string | string[] | undefined): string[] {
|
|
59
|
+
if (value == null) return [];
|
|
60
|
+
return Array.isArray(value) ? value : [value];
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
function scoreText(document: SearchDocument, tokens: string[]): number {
|
|
43
64
|
if (tokens.length === 0) return 1;
|
|
44
65
|
|
|
@@ -109,16 +130,43 @@ export class SearchService {
|
|
|
109
130
|
entity: SellableEntity,
|
|
110
131
|
ctx?: TxContext,
|
|
111
132
|
): Promise<SearchDocument> {
|
|
112
|
-
const
|
|
133
|
+
const localizedAttributes =
|
|
113
134
|
await this.deps.catalogRepository.findAttributesByEntityId(
|
|
114
135
|
entity.id,
|
|
115
136
|
ctx,
|
|
116
137
|
);
|
|
117
|
-
const primary =
|
|
138
|
+
const primary = localizedAttributes[0];
|
|
118
139
|
const title = primary?.title ?? entity.slug;
|
|
119
140
|
const description = primary?.description;
|
|
120
141
|
const categories = await this.entityCategories(entity.id, ctx);
|
|
121
142
|
const brands = await this.entityBrands(entity.id, ctx);
|
|
143
|
+
const definitions = this.deps.resolveEntityFieldDefinitions
|
|
144
|
+
? await this.deps.resolveEntityFieldDefinitions(entity.type, entity.organizationId, ctx)
|
|
145
|
+
: (this.deps.entities?.[entity.type]?.fields ?? []);
|
|
146
|
+
const filterableFields = new Set(
|
|
147
|
+
definitions
|
|
148
|
+
.filter((field) => field.filterable === true)
|
|
149
|
+
.map((field) => field.name),
|
|
150
|
+
);
|
|
151
|
+
const customFields = await this.deps.catalogRepository.findCustomFieldsByEntityId(
|
|
152
|
+
entity.id,
|
|
153
|
+
ctx,
|
|
154
|
+
);
|
|
155
|
+
const attributeValuesByName = new Map<string, string[]>();
|
|
156
|
+
for (const field of customFields) {
|
|
157
|
+
if (field.locale !== "en" || !filterableFields.has(field.fieldName)) continue;
|
|
158
|
+
const value = customFieldValue(field);
|
|
159
|
+
if (value == null) continue;
|
|
160
|
+
const values = attributeValuesByName.get(field.fieldName) ?? [];
|
|
161
|
+
if (!values.includes(value)) values.push(value);
|
|
162
|
+
attributeValuesByName.set(field.fieldName, values);
|
|
163
|
+
}
|
|
164
|
+
const attributes = Object.fromEntries(
|
|
165
|
+
[...attributeValuesByName.entries()].map(([name, values]) => [
|
|
166
|
+
name,
|
|
167
|
+
values.length === 1 ? values[0]! : values,
|
|
168
|
+
]),
|
|
169
|
+
) as Record<string, string | string[]>;
|
|
122
170
|
|
|
123
171
|
const textParts: string[] = [
|
|
124
172
|
entity.slug,
|
|
@@ -126,8 +174,8 @@ export class SearchService {
|
|
|
126
174
|
description ?? "",
|
|
127
175
|
...categories,
|
|
128
176
|
...brands,
|
|
129
|
-
...
|
|
130
|
-
...
|
|
177
|
+
...localizedAttributes.map((attr) => attr.title),
|
|
178
|
+
...localizedAttributes.map((attr) => attr.description ?? ""),
|
|
131
179
|
];
|
|
132
180
|
|
|
133
181
|
return {
|
|
@@ -140,6 +188,7 @@ export class SearchService {
|
|
|
140
188
|
categories,
|
|
141
189
|
brands,
|
|
142
190
|
text: textParts.join(" ").trim(),
|
|
191
|
+
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
|
143
192
|
payload: {
|
|
144
193
|
metadata: entity.metadata ?? undefined,
|
|
145
194
|
},
|
|
@@ -168,6 +217,11 @@ export class SearchService {
|
|
|
168
217
|
if (filters.category && !document.categories.includes(filters.category))
|
|
169
218
|
return false;
|
|
170
219
|
if (filters.brand && !document.brands.includes(filters.brand)) return false;
|
|
220
|
+
for (const [name, requested] of Object.entries(filters.attributes ?? {})) {
|
|
221
|
+
const actual = attributeValues(document.attributes?.[name]);
|
|
222
|
+
const expected = attributeValues(requested);
|
|
223
|
+
if (!expected.some((value) => actual.includes(value))) return false;
|
|
224
|
+
}
|
|
171
225
|
return true;
|
|
172
226
|
}
|
|
173
227
|
|
|
@@ -214,6 +268,26 @@ export class SearchService {
|
|
|
214
268
|
}
|
|
215
269
|
}
|
|
216
270
|
}
|
|
271
|
+
|
|
272
|
+
const attributeName = facet.startsWith("attributes.")
|
|
273
|
+
? facet.slice("attributes.".length)
|
|
274
|
+
: facet;
|
|
275
|
+
if (
|
|
276
|
+
attributeName !== "type" &&
|
|
277
|
+
attributeName !== "status" &&
|
|
278
|
+
attributeName !== "category" &&
|
|
279
|
+
attributeName !== "categories" &&
|
|
280
|
+
attributeName !== "brand" &&
|
|
281
|
+
attributeName !== "brands"
|
|
282
|
+
) {
|
|
283
|
+
const values: Record<string, number> = {};
|
|
284
|
+
for (const document of documents) {
|
|
285
|
+
for (const value of new Set(attributeValues(document.attributes?.[attributeName]))) {
|
|
286
|
+
values[value] = (values[value] ?? 0) + 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (Object.keys(values).length > 0) output[attributeName] = values;
|
|
290
|
+
}
|
|
217
291
|
}
|
|
218
292
|
|
|
219
293
|
return output;
|
|
@@ -255,7 +329,14 @@ export class SearchService {
|
|
|
255
329
|
// In-memory fallback: require a non-empty query to avoid loading all entities
|
|
256
330
|
// (allDocuments triggers N+1 DB queries per entity — unsafe at scale)
|
|
257
331
|
const tokens = tokenize(safeQuery);
|
|
258
|
-
if (
|
|
332
|
+
if (
|
|
333
|
+
tokens.length === 0 &&
|
|
334
|
+
!params.filters?.type &&
|
|
335
|
+
!params.filters?.category &&
|
|
336
|
+
!params.filters?.brand &&
|
|
337
|
+
!params.filters?.status &&
|
|
338
|
+
Object.keys(params.filters?.attributes ?? {}).length === 0
|
|
339
|
+
) {
|
|
259
340
|
return Ok({ hits: [], total: 0, page, limit, facets: {} });
|
|
260
341
|
}
|
|
261
342
|
|
package/src/utils/id.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
export function makeId(): string {
|
|
2
2
|
return crypto.randomUUID();
|
|
3
3
|
}
|
|
4
|
+
|
|
5
|
+
export async function makeDeterministicId(value: string): Promise<string> {
|
|
6
|
+
const digest = new Uint8Array(
|
|
7
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
|
|
8
|
+
);
|
|
9
|
+
const bytes = digest.slice(0, 16);
|
|
10
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x80;
|
|
11
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
12
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
13
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
14
|
+
}
|