@fayz-ai/shop 0.7.0 → 0.8.2
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/README.md +2 -0
- package/dist/index.cjs +185 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -28
- package/dist/index.js.map +1 -1
- package/dist/mock-provider.d.ts.map +1 -1
- package/dist/mock.cjs +5 -0
- package/dist/mock.cjs.map +1 -1
- package/dist/mock.js +5 -0
- package/dist/mock.js.map +1 -1
- package/dist/provider.d.ts +42 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/runtime.cjs +5 -0
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +5 -0
- package/dist/runtime.js.map +1 -1
- package/dist/supabase-provider.d.ts +20 -1
- package/dist/supabase-provider.d.ts.map +1 -1
- package/dist/tables.d.ts +11 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -4
- package/src/index.ts +4 -0
- package/src/mock-provider.ts +5 -0
- package/src/provider.ts +45 -0
- package/src/supabase-provider.ts +226 -34
- package/src/tables.ts +16 -0
- package/src/types.ts +110 -0
package/src/supabase-provider.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
2
2
|
import type { ShopProvider } from './provider'
|
|
3
3
|
import { getShopTenantId } from './tenant'
|
|
4
|
+
import { T } from './tables'
|
|
4
5
|
import type {
|
|
5
6
|
Product, ProductImage, Category,
|
|
6
7
|
Order, ShopCustomer, Discount,
|
|
@@ -8,8 +9,8 @@ import type {
|
|
|
8
9
|
CreateCategoryInput, UpdateCategoryInput,
|
|
9
10
|
CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
|
|
10
11
|
CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
|
|
11
|
-
CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
|
|
12
|
-
} from './types'
|
|
12
|
+
CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions, OrderItem,
|
|
13
|
+
ShippingZone, CreateShippingZoneInput, UpdateShippingZoneInput } from './types'
|
|
13
14
|
|
|
14
15
|
function getDb(): any {
|
|
15
16
|
const supabase = getSupabaseClientOptional()
|
|
@@ -30,6 +31,44 @@ function asUuid(value: string | null | undefined): string | null {
|
|
|
30
31
|
: null
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** 8 bare digits, zero-padded — the shape shipping_zones stores and compares. */
|
|
35
|
+
function toPostalDigits(value: string | undefined | null): string | undefined {
|
|
36
|
+
const digits = String(value ?? '').replace(/\D/g, '')
|
|
37
|
+
return digits ? digits.padStart(8, '0').slice(0, 8) : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rowToShippingZone(row: any): ShippingZone {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
tenantId: row.tenant_id,
|
|
44
|
+
name: row.name,
|
|
45
|
+
carrier: row.carrier ?? null,
|
|
46
|
+
postalFrom: String(row.postal_from ?? '').trim(),
|
|
47
|
+
postalTo: String(row.postal_to ?? '').trim(),
|
|
48
|
+
rate: Number(row.rate ?? 0),
|
|
49
|
+
freeAbove: row.free_above == null ? null : Number(row.free_above),
|
|
50
|
+
etaMinDays: row.eta_min_days ?? null,
|
|
51
|
+
etaMaxDays: row.eta_max_days ?? null,
|
|
52
|
+
active: row.active ?? true,
|
|
53
|
+
sortOrder: row.sort_order ?? 0,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function shippingZonePayload(input: Partial<CreateShippingZoneInput>): Record<string, unknown> {
|
|
58
|
+
const payload: Record<string, unknown> = {}
|
|
59
|
+
if (input.name !== undefined) payload.name = input.name
|
|
60
|
+
if (input.carrier !== undefined) payload.carrier = input.carrier || null
|
|
61
|
+
if (input.postalFrom !== undefined) payload.postal_from = toPostalDigits(input.postalFrom)
|
|
62
|
+
if (input.postalTo !== undefined) payload.postal_to = toPostalDigits(input.postalTo)
|
|
63
|
+
if (input.rate !== undefined) payload.rate = input.rate
|
|
64
|
+
if (input.freeAbove !== undefined) payload.free_above = input.freeAbove ?? null
|
|
65
|
+
if (input.etaMinDays !== undefined) payload.eta_min_days = input.etaMinDays ?? null
|
|
66
|
+
if (input.etaMaxDays !== undefined) payload.eta_max_days = input.etaMaxDays ?? null
|
|
67
|
+
if (input.active !== undefined) payload.active = input.active
|
|
68
|
+
if (input.sortOrder !== undefined) payload.sort_order = input.sortOrder
|
|
69
|
+
return payload
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
function rowToProduct(row: any): Product {
|
|
34
73
|
return {
|
|
35
74
|
id: row.id,
|
|
@@ -81,6 +120,26 @@ function rowToCategory(row: any): Category {
|
|
|
81
120
|
}
|
|
82
121
|
}
|
|
83
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Order items were being passed through raw, so every snake_case column arrived
|
|
125
|
+
* undefined on the camelCase type: unit_price, image_url and product_id were all
|
|
126
|
+
* lost. `total` survived only because the name matches in both, which is exactly
|
|
127
|
+
* why the bug looked like "price is zero" instead of "nothing is mapped".
|
|
128
|
+
*/
|
|
129
|
+
function rowToOrderItem(row: any): OrderItem {
|
|
130
|
+
return {
|
|
131
|
+
id: row.id,
|
|
132
|
+
orderId: row.order_id,
|
|
133
|
+
productId: row.product_id ?? null,
|
|
134
|
+
name: row.name,
|
|
135
|
+
sku: row.sku ?? null,
|
|
136
|
+
quantity: row.quantity ?? 0,
|
|
137
|
+
unitPrice: row.unit_price ?? 0,
|
|
138
|
+
total: row.total ?? 0,
|
|
139
|
+
imageUrl: row.image_url ?? null,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
84
143
|
function rowToOrder(row: any): Order {
|
|
85
144
|
return {
|
|
86
145
|
id: row.id,
|
|
@@ -100,7 +159,10 @@ function rowToOrder(row: any): Order {
|
|
|
100
159
|
customerEmail: row.customer_email,
|
|
101
160
|
discountCode: row.discount_code,
|
|
102
161
|
notes: row.notes,
|
|
103
|
-
|
|
162
|
+
shippingAddress: row.shipping_address ?? null,
|
|
163
|
+
paymentMethodKind: row.payment_method_kind ?? null,
|
|
164
|
+
paidAt: row.paid_at ?? null,
|
|
165
|
+
items: Array.isArray(row.items) ? row.items.map(rowToOrderItem) : [],
|
|
104
166
|
createdAt: row.created_at,
|
|
105
167
|
updatedAt: row.updated_at,
|
|
106
168
|
}
|
|
@@ -150,7 +212,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
150
212
|
async listProducts(options?: ListProductsOptions): Promise<Product[]> {
|
|
151
213
|
const db = getDb()
|
|
152
214
|
const tenantId = getTenantId()
|
|
153
|
-
let q = db.from(
|
|
215
|
+
let q = db.from(T.products).select(`*, images:${T.productImages}(*)`)
|
|
154
216
|
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
155
217
|
if (options?.status) q = q.eq('status', options.status)
|
|
156
218
|
if (options?.categoryId) q = q.eq('category_id', options.categoryId)
|
|
@@ -167,8 +229,8 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
167
229
|
async getProduct(id: string): Promise<Product | null> {
|
|
168
230
|
const db = getDb()
|
|
169
231
|
const { data, error } = await db
|
|
170
|
-
.from(
|
|
171
|
-
.select(
|
|
232
|
+
.from(T.products)
|
|
233
|
+
.select(`*, images:${T.productImages}(*)`)
|
|
172
234
|
.eq('id', id)
|
|
173
235
|
.maybeSingle()
|
|
174
236
|
if (error) throw error
|
|
@@ -179,7 +241,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
179
241
|
const db = getDb()
|
|
180
242
|
const tenantId = getTenantId()
|
|
181
243
|
const { data, error } = await db
|
|
182
|
-
.from(
|
|
244
|
+
.from(T.products)
|
|
183
245
|
.insert({
|
|
184
246
|
tenant_id: tenantId,
|
|
185
247
|
name: input.name,
|
|
@@ -198,7 +260,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
198
260
|
weight: input.weight ?? null,
|
|
199
261
|
weight_unit: input.weightUnit ?? 'kg',
|
|
200
262
|
})
|
|
201
|
-
.select(
|
|
263
|
+
.select(`*, images:${T.productImages}(*)`)
|
|
202
264
|
.single()
|
|
203
265
|
if (error) throw error
|
|
204
266
|
return rowToProduct(data)
|
|
@@ -220,15 +282,15 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
220
282
|
if (input.isPhysical !== undefined) updates.is_physical = input.isPhysical
|
|
221
283
|
if (input.weight !== undefined) updates.weight = input.weight
|
|
222
284
|
const { data, error } = await db
|
|
223
|
-
.from(
|
|
224
|
-
.select(
|
|
285
|
+
.from(T.products).update(updates).eq('id', id)
|
|
286
|
+
.select(`*, images:${T.productImages}(*)`)
|
|
225
287
|
.single()
|
|
226
288
|
if (error) throw error
|
|
227
289
|
return rowToProduct(data)
|
|
228
290
|
}
|
|
229
291
|
|
|
230
292
|
async deleteProduct(id: string): Promise<void> {
|
|
231
|
-
const { error } = await getDb().from(
|
|
293
|
+
const { error } = await getDb().from(T.products).delete().eq('id', id)
|
|
232
294
|
if (error) throw error
|
|
233
295
|
}
|
|
234
296
|
|
|
@@ -239,16 +301,63 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
239
301
|
const { error: uploadErr } = await db.storage.from('shop-images').upload(path, file)
|
|
240
302
|
if (uploadErr) throw uploadErr
|
|
241
303
|
const { data: { publicUrl } } = db.storage.from('shop-images').getPublicUrl(path)
|
|
304
|
+
|
|
305
|
+
// Every upload used to land as sort_order 0 / is_primary false, so a product
|
|
306
|
+
// whose only photo was uploaded here had NO primary at all, and a second one
|
|
307
|
+
// tied with the first for position. New photos now go to the end, and the
|
|
308
|
+
// first photo a product ever gets becomes its primary.
|
|
309
|
+
const { data: existing } = await db
|
|
310
|
+
.from(T.productImages)
|
|
311
|
+
.select('id,sort_order')
|
|
312
|
+
.eq('product_id', productId)
|
|
313
|
+
.order('sort_order', { ascending: false })
|
|
314
|
+
.limit(1)
|
|
315
|
+
const first = !existing || existing.length === 0
|
|
316
|
+
const nextOrder = first ? 0 : (existing[0].sort_order ?? 0) + 1
|
|
317
|
+
|
|
242
318
|
const { data, error } = await db
|
|
243
|
-
.from(
|
|
244
|
-
.insert({
|
|
319
|
+
.from(T.productImages)
|
|
320
|
+
.insert({
|
|
321
|
+
product_id: productId, tenant_id: tenantId, url: publicUrl,
|
|
322
|
+
sort_order: nextOrder, is_primary: first,
|
|
323
|
+
})
|
|
245
324
|
.select().single()
|
|
246
325
|
if (error) throw error
|
|
247
326
|
return rowToImage(data)
|
|
248
327
|
}
|
|
249
328
|
|
|
329
|
+
async updateProductImage(
|
|
330
|
+
imageId: string,
|
|
331
|
+
input: { isPrimary?: boolean; sortOrder?: number; altText?: string | null },
|
|
332
|
+
): Promise<ProductImage> {
|
|
333
|
+
const db = getDb()
|
|
334
|
+
const updates: Record<string, unknown> = {}
|
|
335
|
+
if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder
|
|
336
|
+
if (input.altText !== undefined) updates.alt_text = input.altText
|
|
337
|
+
if (input.isPrimary !== undefined) updates.is_primary = input.isPrimary
|
|
338
|
+
|
|
339
|
+
// Demote the incumbent first. Doing it after would leave a window with two
|
|
340
|
+
// primaries, and if the second statement failed the product would keep two
|
|
341
|
+
// for good.
|
|
342
|
+
if (input.isPrimary) {
|
|
343
|
+
const { data: target } = await db
|
|
344
|
+
.from(T.productImages).select('product_id').eq('id', imageId).single()
|
|
345
|
+
if (target?.product_id) {
|
|
346
|
+
await db.from(T.productImages)
|
|
347
|
+
.update({ is_primary: false })
|
|
348
|
+
.eq('product_id', target.product_id)
|
|
349
|
+
.neq('id', imageId)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const { data, error } = await db
|
|
354
|
+
.from(T.productImages).update(updates).eq('id', imageId).select().single()
|
|
355
|
+
if (error) throw error
|
|
356
|
+
return rowToImage(data)
|
|
357
|
+
}
|
|
358
|
+
|
|
250
359
|
async deleteProductImage(imageId: string): Promise<void> {
|
|
251
|
-
const { error } = await getDb().from(
|
|
360
|
+
const { error } = await getDb().from(T.productImages).delete().eq('id', imageId)
|
|
252
361
|
if (error) throw error
|
|
253
362
|
}
|
|
254
363
|
|
|
@@ -259,7 +368,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
259
368
|
async listCategories(): Promise<Category[]> {
|
|
260
369
|
const db = getDb()
|
|
261
370
|
const tenantId = getTenantId()
|
|
262
|
-
let q = db.from(
|
|
371
|
+
let q = db.from(T.categories).select('*').order('sort_order', { ascending: true })
|
|
263
372
|
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
264
373
|
const { data, error } = await q
|
|
265
374
|
if (error) throw error
|
|
@@ -269,7 +378,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
269
378
|
async createCategory(input: CreateCategoryInput): Promise<Category> {
|
|
270
379
|
const db = getDb()
|
|
271
380
|
const { data, error } = await db
|
|
272
|
-
.from(
|
|
381
|
+
.from(T.categories)
|
|
273
382
|
.insert({ tenant_id: getTenantId(), name: input.name, slug: input.slug ?? slugify(input.name), description: input.description ?? null, parent_id: input.parentId ?? null, sort_order: input.sortOrder ?? 0 })
|
|
274
383
|
.select().single()
|
|
275
384
|
if (error) throw error
|
|
@@ -284,13 +393,13 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
284
393
|
if (input.description !== undefined) updates.description = input.description
|
|
285
394
|
if (input.parentId !== undefined) updates.parent_id = input.parentId
|
|
286
395
|
if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder
|
|
287
|
-
const { data, error } = await db.from(
|
|
396
|
+
const { data, error } = await db.from(T.categories).update(updates).eq('id', id).select().single()
|
|
288
397
|
if (error) throw error
|
|
289
398
|
return rowToCategory(data)
|
|
290
399
|
}
|
|
291
400
|
|
|
292
401
|
async deleteCategory(id: string): Promise<void> {
|
|
293
|
-
const { error } = await getDb().from(
|
|
402
|
+
const { error } = await getDb().from(T.categories).delete().eq('id', id)
|
|
294
403
|
if (error) throw error
|
|
295
404
|
}
|
|
296
405
|
|
|
@@ -301,7 +410,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
301
410
|
async listOrders(options?: ListOrdersOptions): Promise<Order[]> {
|
|
302
411
|
const db = getDb()
|
|
303
412
|
const tenantId = getTenantId()
|
|
304
|
-
let q = db.from(
|
|
413
|
+
let q = db.from(T.orders).select(`*, items:${T.orderItems}(*)`)
|
|
305
414
|
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
306
415
|
if (options?.status) q = q.eq('status', options.status)
|
|
307
416
|
if (options?.financialStatus) q = q.eq('financial_status', options.financialStatus)
|
|
@@ -318,7 +427,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
318
427
|
|
|
319
428
|
async getOrder(id: string): Promise<Order | null> {
|
|
320
429
|
const { data, error } = await getDb()
|
|
321
|
-
.from(
|
|
430
|
+
.from(T.orders).select(`*, items:${T.orderItems}(*)`)
|
|
322
431
|
.eq('id', id).maybeSingle()
|
|
323
432
|
if (error) throw error
|
|
324
433
|
return data ? rowToOrder(data) : null
|
|
@@ -332,12 +441,12 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
332
441
|
const shippingTotal = input.shippingTotal ?? 0
|
|
333
442
|
const total = Math.round((subtotal - discountTotal + shippingTotal) * 100) / 100
|
|
334
443
|
const { data: order, error: orderErr } = await db
|
|
335
|
-
.from(
|
|
444
|
+
.from(T.orders)
|
|
336
445
|
.insert({ tenant_id: tenantId, customer_id: input.customerId ?? null, customer_name: input.customerName ?? null, customer_email: input.customerEmail ?? null, currency: input.currency ?? 'BRL', notes: input.notes ?? null, subtotal, discount_total: discountTotal, shipping_total: shippingTotal, discount_code: input.discountCode ?? null, total, status: 'open', financial_status: 'pending', fulfillment_status: 'unfulfilled' })
|
|
337
446
|
.select().single()
|
|
338
447
|
if (orderErr) throw orderErr
|
|
339
448
|
const items = input.items.map(i => ({ order_id: order.id, product_id: asUuid(i.productId), name: i.name, sku: i.sku ?? null, quantity: i.quantity, unit_price: i.unitPrice, total: i.quantity * i.unitPrice, image_url: i.imageUrl ?? null }))
|
|
340
|
-
await db.from(
|
|
449
|
+
await db.from(T.orderItems).insert(items)
|
|
341
450
|
return this.getOrder(order.id) as Promise<Order>
|
|
342
451
|
}
|
|
343
452
|
|
|
@@ -347,11 +456,76 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
347
456
|
if (input.financialStatus !== undefined) updates.financial_status = input.financialStatus
|
|
348
457
|
if (input.fulfillmentStatus !== undefined) updates.fulfillment_status = input.fulfillmentStatus
|
|
349
458
|
if (input.notes !== undefined) updates.notes = input.notes
|
|
350
|
-
const { error } = await getDb().from(
|
|
459
|
+
const { error } = await getDb().from(T.orders).update(updates).eq('id', id)
|
|
351
460
|
if (error) throw error
|
|
352
461
|
return this.getOrder(id) as Promise<Order>
|
|
353
462
|
}
|
|
354
463
|
|
|
464
|
+
// -------------------------------------------------------------------------
|
|
465
|
+
// Delivery zones
|
|
466
|
+
//
|
|
467
|
+
// Postal codes are stored as 8 bare digits so the range test in
|
|
468
|
+
// shop_quote_shipping is a plain BETWEEN. Accepting '22.041-001' from the
|
|
469
|
+
// form and normalising here means the merchant never has to think about it.
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
async listShippingZones(): Promise<ShippingZone[]> {
|
|
472
|
+
const tenantId = getTenantId()
|
|
473
|
+
const { data, error } = await getDb()
|
|
474
|
+
.from(T.shippingZones)
|
|
475
|
+
.select('*')
|
|
476
|
+
.eq('tenant_id', tenantId)
|
|
477
|
+
.order('sort_order', { ascending: true })
|
|
478
|
+
.order('postal_from', { ascending: true })
|
|
479
|
+
if (error) throw error
|
|
480
|
+
return (data ?? []).map(rowToShippingZone)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async createShippingZone(input: CreateShippingZoneInput): Promise<ShippingZone> {
|
|
484
|
+
const tenantId = getTenantId()
|
|
485
|
+
if (!tenantId) throw new Error('@fayz-ai/shop: createShippingZone requires a tenant id.')
|
|
486
|
+
const { data, error } = await getDb()
|
|
487
|
+
.from(T.shippingZones)
|
|
488
|
+
.insert({ tenant_id: tenantId, ...shippingZonePayload(input) })
|
|
489
|
+
.select()
|
|
490
|
+
.single()
|
|
491
|
+
if (error) throw error
|
|
492
|
+
return rowToShippingZone(data)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async updateShippingZone(id: string, input: UpdateShippingZoneInput): Promise<ShippingZone> {
|
|
496
|
+
const { data, error } = await getDb()
|
|
497
|
+
.from(T.shippingZones)
|
|
498
|
+
.update(shippingZonePayload(input))
|
|
499
|
+
.eq('id', id)
|
|
500
|
+
.select()
|
|
501
|
+
.single()
|
|
502
|
+
if (error) throw error
|
|
503
|
+
return rowToShippingZone(data)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async deleteShippingZone(id: string): Promise<void> {
|
|
507
|
+
const { error } = await getDb().from(T.shippingZones).delete().eq('id', id)
|
|
508
|
+
if (error) throw error
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Settle a pending order: the merchant confirming that the money arrived.
|
|
513
|
+
*
|
|
514
|
+
* Goes through the RPC rather than an UPDATE so the payment ledger row in
|
|
515
|
+
* public.transactions closes in the same transaction as the order — set
|
|
516
|
+
* financial_status by hand and the order says paid while the ledger still
|
|
517
|
+
* says pending. Since 0019 the RPC also enforces who may call it: a member of
|
|
518
|
+
* the owning tenant, or a PSP webhook with the service role.
|
|
519
|
+
*/
|
|
520
|
+
async confirmPayment(id: string, reference?: string): Promise<Order | null> {
|
|
521
|
+
const { error } = await getDb().rpc('shop_confirm_payment', {
|
|
522
|
+
p_order_id: id,
|
|
523
|
+
p_reference: reference ?? null,
|
|
524
|
+
})
|
|
525
|
+
if (error) throw error
|
|
526
|
+
return this.getOrder(id)
|
|
527
|
+
}
|
|
528
|
+
|
|
355
529
|
// Trusted placement — delegates all price/discount/inventory authority to the
|
|
356
530
|
// shop_place_order RPC (SECURITY DEFINER). The browser never sets a price.
|
|
357
531
|
async placeOrder(input: PlaceOrderInput): Promise<Order> {
|
|
@@ -366,7 +540,25 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
366
540
|
p_customer_email: input.customer?.email ?? null,
|
|
367
541
|
p_currency: input.currency ?? 'BRL',
|
|
368
542
|
p_discount_code: input.discountCode ?? null,
|
|
369
|
-
|
|
543
|
+
p_shipping_total: input.shippingTotal ?? 0,
|
|
544
|
+
// snake_case keys on purpose: the RPC freezes this jsonb onto the
|
|
545
|
+
// order and public.addresses uses these exact column names.
|
|
546
|
+
p_shipping_address: input.shippingAddress
|
|
547
|
+
? {
|
|
548
|
+
postal_code: input.shippingAddress.postalCode,
|
|
549
|
+
street: input.shippingAddress.street,
|
|
550
|
+
number: input.shippingAddress.number ?? null,
|
|
551
|
+
complement: input.shippingAddress.complement ?? null,
|
|
552
|
+
district: input.shippingAddress.district ?? null,
|
|
553
|
+
city: input.shippingAddress.city,
|
|
554
|
+
state: input.shippingAddress.state,
|
|
555
|
+
country: input.shippingAddress.country ?? 'BR',
|
|
556
|
+
recipient: input.shippingAddress.recipient ?? null,
|
|
557
|
+
phone: input.shippingAddress.phone ?? null,
|
|
558
|
+
label: input.shippingAddress.label ?? null,
|
|
559
|
+
}
|
|
560
|
+
: null,
|
|
561
|
+
p_payment_method: input.paymentMethod ?? null,
|
|
370
562
|
p_notes: input.notes ?? null,
|
|
371
563
|
})
|
|
372
564
|
if (error) throw error
|
|
@@ -383,7 +575,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
383
575
|
async listCustomers(options?: ListCustomersOptions): Promise<ShopCustomer[]> {
|
|
384
576
|
const db = getDb()
|
|
385
577
|
const tenantId = getTenantId()
|
|
386
|
-
let q = db.from(
|
|
578
|
+
let q = db.from(T.customers).select('*').order('first_name', { ascending: true })
|
|
387
579
|
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
388
580
|
if (options?.search) q = q.or(`first_name.ilike.%${options.search}%,last_name.ilike.%${options.search}%,email.ilike.%${options.search}%`)
|
|
389
581
|
if (options?.limit) q = q.limit(options.limit)
|
|
@@ -393,14 +585,14 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
393
585
|
}
|
|
394
586
|
|
|
395
587
|
async getCustomer(id: string): Promise<ShopCustomer | null> {
|
|
396
|
-
const { data, error } = await getDb().from(
|
|
588
|
+
const { data, error } = await getDb().from(T.customers).select('*').eq('id', id).maybeSingle()
|
|
397
589
|
if (error) throw error
|
|
398
590
|
return data ? rowToCustomer(data) : null
|
|
399
591
|
}
|
|
400
592
|
|
|
401
593
|
async createCustomer(input: CreateCustomerInput): Promise<ShopCustomer> {
|
|
402
594
|
const { data, error } = await getDb()
|
|
403
|
-
.from(
|
|
595
|
+
.from(T.customers)
|
|
404
596
|
.insert({ tenant_id: getTenantId(), first_name: input.firstName, last_name: input.lastName ?? '', email: input.email ?? null, phone: input.phone ?? null, notes: input.notes ?? null })
|
|
405
597
|
.select().single()
|
|
406
598
|
if (error) throw error
|
|
@@ -414,13 +606,13 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
414
606
|
if (input.email !== undefined) updates.email = input.email
|
|
415
607
|
if (input.phone !== undefined) updates.phone = input.phone
|
|
416
608
|
if (input.notes !== undefined) updates.notes = input.notes
|
|
417
|
-
const { data, error } = await getDb().from(
|
|
609
|
+
const { data, error } = await getDb().from(T.customers).update(updates).eq('id', id).select().single()
|
|
418
610
|
if (error) throw error
|
|
419
611
|
return rowToCustomer(data)
|
|
420
612
|
}
|
|
421
613
|
|
|
422
614
|
async deleteCustomer(id: string): Promise<void> {
|
|
423
|
-
const { error } = await getDb().from(
|
|
615
|
+
const { error } = await getDb().from(T.customers).delete().eq('id', id)
|
|
424
616
|
if (error) throw error
|
|
425
617
|
}
|
|
426
618
|
|
|
@@ -448,7 +640,7 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
448
640
|
async listDiscounts(options?: ListDiscountsOptions): Promise<Discount[]> {
|
|
449
641
|
const db = getDb()
|
|
450
642
|
const tenantId = getTenantId()
|
|
451
|
-
let q = db.from(
|
|
643
|
+
let q = db.from(T.discounts).select('*').order('created_at', { ascending: false })
|
|
452
644
|
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
453
645
|
if (options?.status) q = q.eq('status', options.status)
|
|
454
646
|
if (options?.type) q = q.eq('type', options.type)
|
|
@@ -460,14 +652,14 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
460
652
|
}
|
|
461
653
|
|
|
462
654
|
async getDiscount(id: string): Promise<Discount | null> {
|
|
463
|
-
const { data, error } = await getDb().from(
|
|
655
|
+
const { data, error } = await getDb().from(T.discounts).select('*').eq('id', id).maybeSingle()
|
|
464
656
|
if (error) throw error
|
|
465
657
|
return data ? rowToDiscount(data) : null
|
|
466
658
|
}
|
|
467
659
|
|
|
468
660
|
async createDiscount(input: CreateDiscountInput): Promise<Discount> {
|
|
469
661
|
const { data, error } = await getDb()
|
|
470
|
-
.from(
|
|
662
|
+
.from(T.discounts)
|
|
471
663
|
.insert({ tenant_id: getTenantId(), title: input.title, code: input.code ?? null, type: input.type, method: input.method ?? 'code', value: input.value, usage_limit: input.usageLimit ?? null, once_per_customer: input.oncePerCustomer ?? false, starts_at: input.startsAt ?? new Date().toISOString(), ends_at: input.endsAt ?? null, status: input.status ?? 'active', times_used: 0 })
|
|
472
664
|
.select().single()
|
|
473
665
|
if (error) throw error
|
|
@@ -483,13 +675,13 @@ export class SupabaseShopProvider implements ShopProvider {
|
|
|
483
675
|
if (input.usageLimit !== undefined) updates.usage_limit = input.usageLimit
|
|
484
676
|
if (input.status !== undefined) updates.status = input.status
|
|
485
677
|
if (input.endsAt !== undefined) updates.ends_at = input.endsAt
|
|
486
|
-
const { data, error } = await getDb().from(
|
|
678
|
+
const { data, error } = await getDb().from(T.discounts).update(updates).eq('id', id).select().single()
|
|
487
679
|
if (error) throw error
|
|
488
680
|
return rowToDiscount(data)
|
|
489
681
|
}
|
|
490
682
|
|
|
491
683
|
async deleteDiscount(id: string): Promise<void> {
|
|
492
|
-
const { error } = await getDb().from(
|
|
684
|
+
const { error } = await getDb().from(T.discounts).delete().eq('id', id)
|
|
493
685
|
if (error) throw error
|
|
494
686
|
}
|
|
495
687
|
}
|
package/src/tables.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Physical table names for @fayz-ai/shop. Renamed to the plg_shop_* industry-pool
|
|
2
|
+
// prefix (was shop_*) when the core DB moved into the public schema. RPC function
|
|
3
|
+
// names (shop_place_order, shop_get_order, shop_resolve_customer) are unchanged.
|
|
4
|
+
export const T = {
|
|
5
|
+
products: 'plg_shop_products',
|
|
6
|
+
productImages: 'plg_shop_product_images',
|
|
7
|
+
categories: 'plg_shop_categories',
|
|
8
|
+
orders: 'plg_shop_orders',
|
|
9
|
+
orderItems: 'plg_shop_order_items',
|
|
10
|
+
customers: 'plg_shop_customers',
|
|
11
|
+
discounts: 'plg_shop_discounts',
|
|
12
|
+
// Core, not plg_shop_*: a delivery zone is a fact about the business, and
|
|
13
|
+
// fulfilment and financial read it too. Same reasoning as addresses and
|
|
14
|
+
// orders — see migration 0021.
|
|
15
|
+
shippingZones: 'shipping_zones',
|
|
16
|
+
} as const
|
package/src/types.ts
CHANGED
|
@@ -191,6 +191,11 @@ export interface Order {
|
|
|
191
191
|
customerEmail: string | null
|
|
192
192
|
discountCode: string | null
|
|
193
193
|
notes: string | null
|
|
194
|
+
/** Frozen delivery address captured at purchase time (0010/0012). */
|
|
195
|
+
shippingAddress: ShippingAddressSnapshot | null
|
|
196
|
+
/** How the buyer paid — opens the ledger row in public.transactions. */
|
|
197
|
+
paymentMethodKind: PaymentMethodKind | null
|
|
198
|
+
paidAt: string | null
|
|
194
199
|
items: OrderItem[]
|
|
195
200
|
createdAt: string
|
|
196
201
|
updatedAt: string
|
|
@@ -266,6 +271,106 @@ export interface PlaceOrderLine {
|
|
|
266
271
|
* storefront order. Prices, discount math, and inventory are resolved
|
|
267
272
|
* server-side; the client supplies product ids, quantities, and intent only.
|
|
268
273
|
*/
|
|
274
|
+
/** Structured delivery address. Mirrors public.addresses so the RPC can both
|
|
275
|
+
* freeze it on the order and save it to the customer's address book. */
|
|
276
|
+
export interface ShippingAddressInput {
|
|
277
|
+
postalCode: string
|
|
278
|
+
street: string
|
|
279
|
+
number?: string
|
|
280
|
+
complement?: string
|
|
281
|
+
district?: string
|
|
282
|
+
city: string
|
|
283
|
+
state: string
|
|
284
|
+
country?: string
|
|
285
|
+
recipient?: string
|
|
286
|
+
phone?: string
|
|
287
|
+
label?: string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type PaymentMethodKind = 'pix' | 'credit_card' | 'debit_card' | 'boleto' | 'cash' | 'other'
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A range of postal codes the store delivers to, with its own price and ETA.
|
|
294
|
+
*
|
|
295
|
+
* `postalFrom`/`postalTo` are 8 digits, no punctuation. Ranges may overlap: the
|
|
296
|
+
* cheapest match wins, which is how a merchant layers a promotional zone over a
|
|
297
|
+
* broader one without deleting anything.
|
|
298
|
+
*/
|
|
299
|
+
export interface ShippingZone {
|
|
300
|
+
id: string
|
|
301
|
+
tenantId: string
|
|
302
|
+
name: string
|
|
303
|
+
carrier?: string | null
|
|
304
|
+
postalFrom: string
|
|
305
|
+
postalTo: string
|
|
306
|
+
rate: number
|
|
307
|
+
/** Overrides the store-wide free-delivery threshold inside this zone. */
|
|
308
|
+
freeAbove?: number | null
|
|
309
|
+
etaMinDays?: number | null
|
|
310
|
+
etaMaxDays?: number | null
|
|
311
|
+
active: boolean
|
|
312
|
+
sortOrder: number
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type CreateShippingZoneInput = Omit<ShippingZone, 'id' | 'tenantId'>
|
|
316
|
+
export type UpdateShippingZoneInput = Partial<CreateShippingZoneInput>
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* One delivery option for a postal code, as quoted by shop_quote_shipping.
|
|
320
|
+
*
|
|
321
|
+
* `rate` already has the zone's free-above threshold applied, so the caller
|
|
322
|
+
* never re-derives a price — the number shown is the number charged. An EMPTY
|
|
323
|
+
* list of options is the meaningful answer "we do not deliver there", not an
|
|
324
|
+
* error condition.
|
|
325
|
+
*/
|
|
326
|
+
export interface ShippingQuoteOption {
|
|
327
|
+
zoneId: string
|
|
328
|
+
name: string
|
|
329
|
+
carrier?: string
|
|
330
|
+
/** Resolved against the subtotal the quote was requested for. */
|
|
331
|
+
rate: number
|
|
332
|
+
/** The zone's price before any free-above rule — lets a caller re-resolve
|
|
333
|
+
* `rate` for a changed cart without another round trip, using the same rule
|
|
334
|
+
* the server applies. Without it a stale quote had to fall back to the
|
|
335
|
+
* store-wide flat rate, which is a different zone's price. */
|
|
336
|
+
baseRate: number
|
|
337
|
+
freeAbove?: number | null
|
|
338
|
+
etaMinDays?: number
|
|
339
|
+
etaMaxDays?: number
|
|
340
|
+
/** True when the rate is 0 because the order cleared the zone's threshold. */
|
|
341
|
+
free: boolean
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A row from the customer's address book (public.addresses).
|
|
346
|
+
*
|
|
347
|
+
* Only reachable with a signed-in shopper: the addresses_self_read policy joins
|
|
348
|
+
* plg_shop_customers.auth_user_id to auth.uid(), so an anonymous request sees
|
|
349
|
+
* nothing. Guests get an empty book and type their address, which is correct —
|
|
350
|
+
* the alternative is showing them somebody else's.
|
|
351
|
+
*/
|
|
352
|
+
export interface CustomerAddress extends ShippingAddressInput {
|
|
353
|
+
id: string
|
|
354
|
+
isDefault?: boolean
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Address as stored on the order: snake_case, mirroring public.addresses. */
|
|
358
|
+
export interface ShippingAddressSnapshot {
|
|
359
|
+
postal_code?: string
|
|
360
|
+
street?: string
|
|
361
|
+
number?: string
|
|
362
|
+
complement?: string
|
|
363
|
+
district?: string
|
|
364
|
+
city?: string
|
|
365
|
+
state?: string
|
|
366
|
+
country?: string
|
|
367
|
+
recipient?: string
|
|
368
|
+
phone?: string
|
|
369
|
+
label?: string
|
|
370
|
+
raw?: string
|
|
371
|
+
source?: string
|
|
372
|
+
}
|
|
373
|
+
|
|
269
374
|
export interface PlaceOrderInput {
|
|
270
375
|
/** Tenant/store id. The Supabase provider falls back to the resolved shop tenant. */
|
|
271
376
|
tenantId?: string
|
|
@@ -279,6 +384,11 @@ export interface PlaceOrderInput {
|
|
|
279
384
|
discountCode?: string
|
|
280
385
|
/** Shipping cost from the store's shipping policy. Clamped to >= 0 server-side. */
|
|
281
386
|
shippingTotal?: number
|
|
387
|
+
/** Structured delivery address. Without it the address survives only as free
|
|
388
|
+
* text in `notes`, which logistics cannot read. */
|
|
389
|
+
shippingAddress?: ShippingAddressInput
|
|
390
|
+
/** How the buyer is paying. Opens the ledger row in public.transactions. */
|
|
391
|
+
paymentMethod?: PaymentMethodKind
|
|
282
392
|
items: PlaceOrderLine[]
|
|
283
393
|
}
|
|
284
394
|
|